feat: NetFlow/IPFIX traffic analytics — per-client usage, app breakdown, built-in collector (v0.13.0-beta)

Adds Meraki-style traffic analytics powered by MikroTik Traffic Flow:

Backend
- Built-in NetFlow v9 + IPFIX collector (UDP 2055) with hand-rolled decoder,
  template caching, enterprise-field and IPv6 support
- Flow attribution: client IP → MAC via the clients table, upload/download
  direction, NAT-duplicate records dropped
- App classification by protocol/port (~25 categories: HTTPS, QUIC, DNS,
  SSH, Email, WireGuard, ...)
- Deduplication: per-client best-exporter-per-window so flows crossing two
  managed routers are never double-counted
- NAT-tolerant ingest: flows from sources matching no managed device (NAT /
  Tailscale subnet routers in path) are accepted as per-source unidentified
  exporters instead of silently dropped (netflow_accept_unknown setting)
- Storage: InfluxDB client_traffic time-series (bounded cardinality via
  top-N fold) + Postgres client_traffic_daily rollups; daily retention purge
- One-toggle device config push via RouterOS API (/ip traffic-flow + target)
- New /api/traffic/* analytics endpoints + /api/network-services/netflow
- docker-compose: NETFLOW_PORT (default 2055/udp) published on the backend

Frontend
- New Traffic page: fleet up/down chart, top talkers with client
  click-through, app-category donut, 1h–30d ranges
- New NetFlow config page: collector settings, live ingest stats,
  per-device export toggles with health badges, NAT-source banner
- Client detail: App Traffic card (auto-hides without data)
- Clients page: sortable "Data (today)" column
- Settings: NetFlow retention controls

Includes 17 unit tests (decoder fixtures, dedup, attribution, classifier);
verified end-to-end against live RouterOS devices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-10 10:58:41 -07:00
co-authored by Claude Fable 5
parent 367034c2d6
commit b841eb6e7b
29 changed files with 2553 additions and 15 deletions
+12
View File
@@ -19,6 +19,13 @@ const REFRESH_OPTIONS = [
const STORAGE_KEY = 'clients-refresh-interval';
function formatDataBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
function readStoredInterval(): number | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'null') return null;
@@ -189,6 +196,7 @@ export default function ClientsPage() {
case 'interface_name': av = a.interface_name || ''; bv = b.interface_name || ''; break;
case 'vlan_id': av = a.vlan_id ?? -1; bv = b.vlan_id ?? -1; break;
case 'device_name': av = a.device_name || ''; bv = b.device_name || ''; break;
case 'traffic_today_bytes': av = a.traffic_today_bytes ?? 0; bv = b.traffic_today_bytes ?? 0; break;
case 'last_seen': av = a.last_seen || ''; bv = b.last_seen || ''; break;
default: av = ''; bv = '';
}
@@ -298,6 +306,7 @@ export default function ClientsPage() {
{ col: 'interface_name', label: 'Port', align: 'left' },
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
{ col: 'device_name', label: 'Device', align: 'left' },
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
<th
@@ -392,6 +401,9 @@ export default function ClientsPage() {
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
{client.device_name || '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">
{client.traffic_today_bytes ? formatDataBytes(client.traffic_today_bytes) : '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">
{client.last_seen
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })