Skip to content

Commit 989790a

Browse files
committed
feat(frontend): add IP subnet view
Displays only aggregated IP activity chart. As there's no support for subnet aggregation in DP3 (it can't be precalculated generically), frontend makes one request for master record of each IP address in subnet. This can potentially cause DoS on huge subnets if not handled correctly. Thus, requests are split into batches of 32 IPs which are fetched in parallel, while batches are processed sequentially. This approach enables us to "animate" data being "loaded" into the chart which seems user-friendly. I consider it a good tradeoff between speed, resources requirements and user experience. There's a new dependency for a small library `netmask` to simplify IP-subnet calculations and bit manipulations. `EntityDetailTemplate` was not used despite the plans, because requesting master records of dynamic EIDs and data aggregation seems out of scope of this generic template.
1 parent 0b3b2e0 commit 989790a

File tree

4 files changed

+208
-0
lines changed

4 files changed

+208
-0
lines changed

frontend/package-lock.json

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"dayjs": "^1.11.13",
1919
"floating-vue": "^5.2.2",
2020
"font-awesome": "^4.7.0",
21+
"netmask": "^2.0.2",
2122
"pinia": "^3.0.1",
2223
"vue": "^3.5.13",
2324
"vue-axios": "^3.5.2",

frontend/src/router/index.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createRouter, createWebHistory } from 'vue-router'
22
import HomeView from '../views/HomeView.vue'
3+
import IPSubnetView from '../views/IPSubnetView.vue'
34
import IPView from '../views/IPView.vue'
45

56
const router = createRouter({
@@ -15,6 +16,11 @@ const router = createRouter({
1516
name: 'ip',
1617
component: IPView,
1718
},
19+
{
20+
path: '/ip_subnet/:address/:prefix',
21+
name: 'ip_subnet',
22+
component: IPSubnetView,
23+
},
1824
],
1925
})
2026

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
<script setup>
2+
import { computed, inject, ref } from 'vue'
3+
import { useRoute } from 'vue-router'
4+
import { Netmask } from 'netmask'
5+
6+
import ActivityTimeline from '@/components/ActivityTimeline.vue'
7+
import ProgressBar from '@/components/ProgressBar.vue'
8+
import SnapshotsTimePickerUrlSync from '@/components/SnapshotsTimePickerUrlSync.vue'
9+
10+
// Number of IP addresses loaded in parallel
11+
const BATCH_SIZE = 32
12+
13+
const getData = inject('getData')
14+
const route = useRoute()
15+
16+
// Parse subnet
17+
const subnetInvalid = ref(false)
18+
let _subnet = null
19+
try {
20+
_subnet = new Netmask(`${route.params.address}/${route.params.prefix}`)
21+
} catch {
22+
subnetInvalid.value = true
23+
}
24+
const subnet = ref(_subnet)
25+
26+
// Primitive mutex
27+
const loading = ref(false)
28+
29+
const timePickerState = ref({
30+
from: null,
31+
to: null,
32+
picked: null,
33+
latest: null,
34+
range: null,
35+
resampleUnitCount: null,
36+
resampleUnit: null,
37+
})
38+
const activity = ref([])
39+
const addressesLoaded = ref(0)
40+
41+
/**
42+
* Number of addresses in subnet
43+
*/
44+
const addressCount = computed(() => {
45+
if (subnetInvalid.value) {
46+
return 0
47+
}
48+
return subnet.value.size - 2
49+
})
50+
51+
/**
52+
* Load progress percentage
53+
*/
54+
const loadProgress = computed(() => {
55+
if (subnetInvalid.value) {
56+
return 0
57+
}
58+
return (addressesLoaded.value / addressCount.value) * 100
59+
})
60+
61+
/**
62+
* Loads data for single IP adress
63+
* @param address IP address
64+
*/
65+
async function loadAddress(address) {
66+
const data = await getData(`/entity/ip/${address}/master`, {
67+
params: {
68+
date_from: timePickerState.value.from,
69+
date_to: timePickerState.value.to,
70+
},
71+
})
72+
73+
if (data?.activity) {
74+
activity.value = activity.value.concat(data.activity)
75+
}
76+
}
77+
78+
/**
79+
* Loads data for batch of IP addresses
80+
*
81+
* All data is loaded in parallel.
82+
*
83+
* @param addresses Array of addresses to load
84+
*/
85+
async function loadBatch(addresses) {
86+
const dataPromises = []
87+
88+
// Iterate over all IPs in subnet
89+
addresses.forEach((ip) => {
90+
// Push promise
91+
dataPromises.push(loadAddress(ip))
92+
})
93+
94+
// Await all promises
95+
await Promise.all(dataPromises)
96+
}
97+
98+
/**
99+
* Loads all data
100+
*
101+
* Subnet is divided into batches of `BATCH_SIZE` addresses. Batches are loaded
102+
* sequentially, but all addresses in a batch are loaded in parallel.
103+
*/
104+
async function load() {
105+
if (subnetInvalid.value) {
106+
return
107+
}
108+
109+
// Prevent multiple loadings at the same time
110+
while (loading.value) {
111+
await new Promise((resolve) => setTimeout(resolve, 100))
112+
}
113+
loading.value = true
114+
115+
// Reset activity
116+
activity.value = []
117+
addressesLoaded.value = 0
118+
119+
// Generate list of all addresses in subnet
120+
let addresses = []
121+
subnet.value.forEach((ip) => {
122+
addresses.push(ip)
123+
})
124+
125+
// Load data in batches
126+
for (let i = 0; i < addresses.length; i += BATCH_SIZE) {
127+
const batch = addresses.slice(i, i + BATCH_SIZE)
128+
129+
// Load batch
130+
await loadBatch(batch)
131+
132+
// Update addresses loaded
133+
addressesLoaded.value += batch.length
134+
}
135+
136+
loading.value = false
137+
}
138+
139+
/**
140+
* Hook to update `timePickerState` value and reload data
141+
*/
142+
async function updateTimePickerState(newTimePickerState) {
143+
timePickerState.value = newTimePickerState
144+
await load()
145+
}
146+
</script>
147+
148+
<template>
149+
<main class="py-4">
150+
<div class="container">
151+
<div v-if="subnetInvalid" class="alert alert-danger">
152+
Invalid subnet <code>{{ $route.params.address }}/{{ $route.params.prefix }}</code>
153+
</div>
154+
<div v-else>
155+
<div class="row mb-5">
156+
<div class="col col-lg-7 title">
157+
<h4>IP subnet</h4>
158+
<h1 class="h2 fw-bold">{{ subnet.base }}/{{ subnet.bitmask }}</h1>
159+
<div class="h5 text-muted">
160+
<span
161+
>{{ subnet.first }} &ndash; {{ subnet.last }} ({{ addressCount }}
162+
{{ addressCount == 1 ? 'address' : 'addresses' }})</span
163+
>
164+
</div>
165+
</div>
166+
<div class="col col-lg-5">
167+
<SnapshotsTimePickerUrlSync @update:timePickerState="updateTimePickerState" />
168+
</div>
169+
</div>
170+
171+
<div>
172+
<h4 class="my-3">
173+
<div class="d-flex align-items-center">
174+
<span>Activity</span>
175+
<ProgressBar v-if="loadProgress < 100" :progress="loadProgress" class="w-100 ms-3" />
176+
</div>
177+
</h4>
178+
<ActivityTimeline
179+
v-if="activity.length > 0"
180+
:activity="activity"
181+
:time-picker-state="timePickerState"
182+
:picked-snapshot-ts="new Date(0)"
183+
:resample-unit-count="timePickerState.resampleUnitCount"
184+
:resample-unit="timePickerState.resampleUnit"
185+
/>
186+
<div v-else class="alert alert-info">No data</div>
187+
</div>
188+
</div>
189+
</div>
190+
</main>
191+
</template>

0 commit comments

Comments
 (0)