From b1dd62bd1fa68cd9e6871c0f84c8047d125fd315 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:32:17 -0700 Subject: [PATCH] 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 --- README.md | 2 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/routes/trafficAnalytics.ts | 61 ++++--- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/src/pages/TrafficAnalyticsPage.tsx | 176 +++++++++++++++++--- frontend/src/services/api.ts | 4 +- frontend/src/version.ts | 2 +- 9 files changed, 207 insertions(+), 50 deletions(-) diff --git a/README.md b/README.md index 4a70cec..496dd37 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface. -![Version](https://img.shields.io/badge/version-0.14.1_Beta-blue) +![Version](https://img.shields.io/badge/version-0.15.0_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package-lock.json b/backend/package-lock.json index 770f330..9e339db 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index e78970e..482072e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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": { diff --git a/backend/src/routes/trafficAnalytics.ts b/backend/src/routes/trafficAnalytics.ts index 12d893e..39996a5 100644 --- a/backend/src/routes/trafficAnalytics.ts +++ b/backend/src/routes/trafficAnalytics.ts @@ -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 = {}; + type Point = { time: string; upload: number; download: number; uploadPackets: number; downloadPackets: number }; + const pivoted: Record = {}; 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(); 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(); 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)), diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8fd3ebf..2842097 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.14.1-beta", + "version": "0.15.0-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.14.1-beta", + "version": "0.15.0-beta", "dependencies": { "@tanstack/react-query": "^5.17.1", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index 3303eb3..424daca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.14.1-beta", + "version": "0.15.0-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/TrafficAnalyticsPage.tsx b/frontend/src/pages/TrafficAnalyticsPage.tsx index 98fb605..26a4402 100644 --- a/frontend/src/pages/TrafficAnalyticsPage.tsx +++ b/frontend/src/pages/TrafficAnalyticsPage.tsx @@ -6,8 +6,8 @@ import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, } from 'recharts'; -import { Activity, ArrowDown, ArrowUp, BarChart3, Settings2 } from 'lucide-react'; -import { format, parseISO } from 'date-fns'; +import { Activity, ArrowDown, ArrowUp, BarChart3, Router as RouterIcon, Settings2, AlertTriangle } from 'lucide-react'; +import { format, parseISO, formatDistanceToNow } from 'date-fns'; import clsx from 'clsx'; import { trafficApi } from '../services/api'; @@ -28,6 +28,13 @@ function formatBytes(bytes: number): string { return `${(bytes / 1024 ** 4).toFixed(2)} TB`; } +function formatCount(n: number): string { + if (n < 1000) return `${n}`; + if (n < 1_000_000) return `${(n / 1000).toFixed(1)}K`; + if (n < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + return `${(n / 1_000_000_000).toFixed(2)}B`; +} + function timeFormatter(range: Range) { return (t: string) => { try { @@ -67,6 +74,12 @@ export default function TrafficAnalyticsPage() { refetchInterval: 60_000, }); + const { data: status } = useQuery({ + queryKey: ['traffic-status'], + queryFn: () => trafficApi.status().then(r => r.data), + refetchInterval: 30_000, + }); + const isLoading = seriesLoading || clientsLoading || appsLoading; const hasData = series.length > 0 || topClients.length > 0 || apps.length > 0; @@ -75,6 +88,12 @@ export default function TrafficAnalyticsPage() { const totalAppBytes = apps.reduce((s, a) => s + a.bytes, 0); const maxClientBytes = topClients[0]?.total_bytes || 1; + // Collector data-health: how much of the received traffic we could attribute + const exporters = [...(status?.exporters || [])].sort((a, b) => b.flows - a.flows); + const pktReceived = status?.packetsReceived || 0; + const pktUnattributed = status?.packetsFromUnknownExporter || 0; + const attributionRate = pktReceived > 0 ? ((pktReceived - pktUnattributed) / pktReceived) * 100 : 0; + // Donut: top 8 categories, rest folded into "Other" const donutData = (() => { const top = apps.slice(0, 8); @@ -178,7 +197,30 @@ export default function TrafficAnalyticsPage() { -
+ {/* Inbound packets chart */} +
+

Inbound Packets

+ + + + + + + + + + + formatCount(v)} tick={{ fontSize: 11 }} width={55} stroke="currentColor" /> + { try { return format(parseISO(String(t)), 'MMM d, HH:mm'); } catch { return String(t); } }} + formatter={(value: number) => [`${formatCount(value)} pkts`, 'Inbound']} + /> + + + +
+ +
{/* Top talkers */}
@@ -194,8 +236,8 @@ export default function TrafficAnalyticsPage() { return (
isReal && navigate(`/clients/${encodeURIComponent(c.mac)}`)} - className={clsx('px-5 py-3', isReal && 'cursor-pointer hover:bg-blue-50 dark:hover:bg-slate-700/40 transition-colors')}> -
+ className={clsx('px-5 py-2', isReal && 'cursor-pointer hover:bg-blue-50 dark:hover:bg-slate-700/40 transition-colors')}> +
{clientLabel(c)} {isReal && ( @@ -207,7 +249,7 @@ export default function TrafficAnalyticsPage() {
-
+
{formatBytes(c.download_bytes)} {formatBytes(c.upload_bytes)}
@@ -218,6 +260,8 @@ export default function TrafficAnalyticsPage() { )}
+ {/* Right column: protocol breakdown + data health, stacked to align with Top Talkers */} +
{/* App categories */}
@@ -227,23 +271,37 @@ export default function TrafficAnalyticsPage() { {apps.length === 0 ?
No application data recorded.
: ( -
- - - - {donutData.map((entry, i) => ( - - ))} - - [formatBytes(value), name]} /> - - -
- {apps.slice(0, 10).map((a, i) => ( +
+ {/* Donut — grows to fill the available space */} +
+ + + + {donutData.map((entry, i) => ( + + ))} + + [ + `${formatBytes(value)}${totalAppBytes > 0 ? ` · ${((value / totalAppBytes) * 100).toFixed(1)}%` : ''}`, + name, + ]} + /> + + + {/* Center label */} +
+ Total + {formatBytes(totalAppBytes)} +
+
+ {/* Legend — uses the vertical space beside the donut */} +
+ {apps.slice(0, 12).map((a, i) => (
{a.app} - + {totalAppBytes > 0 ? `${((a.bytes / totalAppBytes) * 100).toFixed(1)}%` : ''} {formatBytes(a.bytes)} @@ -253,6 +311,84 @@ export default function TrafficAnalyticsPage() {
)}
+ + {/* Flow sources & data health */} +
+
+
+ +

Flow Sources & Data Health

+
+ + + {status?.listening ? `Listening :${status.port}` : 'Collector stopped'} + +
+
+ {/* Health summary */} +
+
+
Packets received
+
{formatCount(pktReceived)}
+
+
+
Flows attributed
+
{formatCount(status?.flowsAttributed || 0)}
+
+
+
Attribution rate
+
= 90 ? 'text-emerald-600 dark:text-emerald-400' + : attributionRate >= 60 ? 'text-amber-600 dark:text-amber-400' + : 'text-red-600 dark:text-red-400')}> + {pktReceived > 0 ? `${attributionRate.toFixed(0)}%` : '—'} +
+
+
+
Exporting devices
+
{exporters.length}
+
+
+ + {/* Low-attribution hint */} + {pktReceived > 0 && attributionRate < 90 && ( +
+ + + {formatCount(pktUnattributed)} packets came from an unrecognized exporter (often NAT in the path). + Their traffic is still counted but may not map to the right device. + +
+ )} + + {/* Per-exporter rows */} + {exporters.length === 0 + ?
No routers have exported flows yet. Configure NetFlow export →
+ : ( +
+ {exporters.map(e => ( +
+
+ + {e.deviceName} +
+
+ {formatCount(e.flows)} flows + {formatCount(e.packets)} pkts + + {e.lastSeen ? `${formatDistanceToNow(parseISO(e.lastSeen))} ago` : 'never'} + +
+
+ ))} +
+ )} +
+
+
)} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9d14567..5ed3c6e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -849,7 +849,7 @@ export interface NetflowDeviceState { // ─── Traffic Analytics (NetFlow) ────────────────────────────────────────────── -export interface ClientTrafficPoint { time: string; upload: number; download: number } +export interface ClientTrafficPoint { time: string; upload: number; download: number; uploadPackets?: number; downloadPackets?: number } export interface TrafficTopClient { mac: string; upload_bytes: number; @@ -860,7 +860,7 @@ export interface TrafficTopClient { vendor: string | null; ip_address: string | null; } -export interface TrafficApp { app: string; bytes: number } +export interface TrafficApp { app: string; bytes: number; packets?: number } export interface TrafficCollectorStats { listening: boolean; port: number; diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 124c25d..c128891 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.14.1 Beta'; +export const APP_VERSION = 'v0.15.0 Beta';