feat(traffic): protocol breakdown layout + inbound packets chart + flow source health (v0.15.0-beta)

- Traffic by Application: donut + legend side-by-side, larger donut with
  center total label, percentage in tooltip; card now sizes to its content
- New Inbound Packets area chart below the throughput chart (packets field
  now summed per direction in /traffic/timeseries)
- New Flow Sources & Data Health card: collector listening state, packets
  received, flows attributed, attribution rate, per-exporter flow/packet
  counts and last-seen; low-attribution hint for NAT-in-path exporters
- /traffic/apps now returns per-app packets alongside bytes
- Restructured bottom row: Top Talkers (left) | Application + Data Health
  stacked (right) for alignment
- Tightened Top Talkers row spacing

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-18 16:32:17 -07:00
co-authored by Claude Opus 4.8
parent d79eb13119
commit b1dd62bd1f
9 changed files with 207 additions and 50 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mikrotik-manager-backend",
"version": "0.14.1-beta",
"version": "0.15.0-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mikrotik-manager-backend",
"version": "0.14.1-beta",
"version": "0.15.0-beta",
"dependencies": {
"@influxdata/influxdb-client": "^1.33.2",
"@influxdata/influxdb-client-apis": "^1.35.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.14.1-beta",
"version": "0.15.0-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+41 -20
View File
@@ -45,20 +45,27 @@ router.get('/timeseries', async (req: Request, res: Response) => {
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_traffic")
|> filter(fn: (r) => r._field == "bytes")
|> group(columns: ["direction"])
|> filter(fn: (r) => r._field == "bytes" or r._field == "packets")
|> group(columns: ["direction", "_field"])
|> aggregateWindow(every: ${every}, fn: sum, createEmpty: false)
`;
const pivoted: Record<string, { time: string; upload: number; download: number }> = {};
type Point = { time: string; upload: number; download: number; uploadPackets: number; downloadPackets: number };
const pivoted: Record<string, Point> = {};
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
const time = tableMeta.get(row, '_time') as string;
const direction = tableMeta.get(row, 'direction') as string;
const field = tableMeta.get(row, '_field') as string;
const value = Number(tableMeta.get(row, '_value')) || 0;
if (!pivoted[time]) pivoted[time] = { time, upload: 0, download: 0 };
if (direction === 'upload') pivoted[time].upload += value;
else if (direction === 'download') pivoted[time].download += value;
if (!pivoted[time]) pivoted[time] = { time, upload: 0, download: 0, uploadPackets: 0, downloadPackets: 0 };
if (field === 'packets') {
if (direction === 'upload') pivoted[time].uploadPackets += value;
else if (direction === 'download') pivoted[time].downloadPackets += value;
} else {
if (direction === 'upload') pivoted[time].upload += value;
else if (direction === 'download') pivoted[time].download += value;
}
});
} catch {
// No data yet
@@ -149,24 +156,31 @@ router.get('/apps', async (req: Request, res: Response) => {
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_traffic")
|> filter(fn: (r) => r._field == "bytes")
|> filter(fn: (r) => r._field == "bytes" or r._field == "packets")
${macFilter}
|> group(columns: ["app"])
|> group(columns: ["app", "_field"])
|> sum()
`;
const apps: { app: string; bytes: number }[] = [];
const byApp = new Map<string, { bytes: number; packets: number }>();
try {
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
apps.push({
app: tableMeta.get(row, 'app') as string,
bytes: Number(tableMeta.get(row, '_value')) || 0,
});
const app = tableMeta.get(row, 'app') as string;
const field = tableMeta.get(row, '_field') as string;
const value = Number(tableMeta.get(row, '_value')) || 0;
let entry = byApp.get(app);
if (!entry) {
entry = { bytes: 0, packets: 0 };
byApp.set(app, entry);
}
if (field === 'packets') entry.packets += value;
else entry.bytes += value;
});
} catch {
// No data yet
}
const apps = Array.from(byApp.entries()).map(([app, e]) => ({ app, bytes: e.bytes, packets: e.packets }));
res.json(apps.sort((a, b) => b.bytes - a.bytes));
});
@@ -206,23 +220,30 @@ router.get('/client/:mac', async (req: Request, res: Response) => {
from(bucket: "${bucket}")
|> range(start: -${range})
|> filter(fn: (r) => r._measurement == "client_traffic")
|> filter(fn: (r) => r._field == "bytes")
|> filter(fn: (r) => r._field == "bytes" or r._field == "packets")
|> filter(fn: (r) => r.mac == "${mac}")
|> group(columns: ["app"])
|> group(columns: ["app", "_field"])
|> sum()
`;
const apps: { app: string; bytes: number }[] = [];
const byApp = new Map<string, { bytes: number; packets: number }>();
try {
await queryApi.collectRows(appsFlux, (row, tableMeta) => {
apps.push({
app: tableMeta.get(row, 'app') as string,
bytes: Number(tableMeta.get(row, '_value')) || 0,
});
const app = tableMeta.get(row, 'app') as string;
const field = tableMeta.get(row, '_field') as string;
const value = Number(tableMeta.get(row, '_value')) || 0;
let entry = byApp.get(app);
if (!entry) {
entry = { bytes: 0, packets: 0 };
byApp.set(app, entry);
}
if (field === 'packets') entry.packets += value;
else entry.bytes += value;
});
} catch {
// No data yet
}
const apps = Array.from(byApp.entries()).map(([app, e]) => ({ app, bytes: e.bytes, packets: e.packets }));
res.json({
series: Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)),