Skip to content

Commit d1a58c2

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.
1 parent 0b3b2e0 commit d1a58c2

File tree

4 files changed

+207
-0
lines changed

4 files changed

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

0 commit comments

Comments
 (0)