import { useState } from 'react'; import { Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend, } from 'recharts'; 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'; type Range = '1h' | '24h' | '7d' | '30d'; const RANGES: Range[] = ['1h', '24h', '7d', '30d']; const APP_COLORS = [ '#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#6366f1', '#14b8a6', '#a855f7', '#64748b', '#0ea5e9', '#d946ef', '#22c55e', ]; function formatBytes(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`; if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(2)} GB`; 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 { const d = parseISO(t); return range === '7d' || range === '30d' ? format(d, 'MMM d') : format(d, 'HH:mm'); } catch { return t; } }; } function clientLabel(c: { mac: string; custom_name: string | null; hostname: string | null; vendor: string | null }): string { if (c.mac === 'unknown') return 'Unattributed (local)'; if (c.mac === 'other') return 'Other clients'; return c.custom_name || c.hostname || c.vendor || c.mac; } export default function TrafficAnalyticsPage() { const [range, setRange] = useState('24h'); const navigate = useNavigate(); const { data: series = [], isLoading: seriesLoading } = useQuery({ queryKey: ['traffic-timeseries', range], queryFn: () => trafficApi.timeseries(range).then(r => r.data), refetchInterval: 60_000, }); const { data: topClients = [], isLoading: clientsLoading } = useQuery({ queryKey: ['traffic-top-clients', range], queryFn: () => trafficApi.topClients(range, 10).then(r => r.data), refetchInterval: 60_000, }); const { data: apps = [], isLoading: appsLoading } = useQuery({ queryKey: ['traffic-apps', range], queryFn: () => trafficApi.apps(range).then(r => r.data), 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; const totalUpload = series.reduce((s, p) => s + p.upload, 0); const totalDownload = series.reduce((s, p) => s + p.download, 0); 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); const rest = apps.slice(8).reduce((s, a) => s + a.bytes, 0); return rest > 0 ? [...top, { app: 'Other', bytes: rest }] : top; })(); return (
{/* Header */}

Traffic

Per-client traffic analytics from NetFlow/IPFIX

{RANGES.map(r => ( ))}
Configure
{/* Empty state */} {!isLoading && !hasData && (

No traffic data yet

Enable the NetFlow collector and turn on traffic export for your routers to start collecting per-client usage and protocol analytics.

Set up NetFlow
)} {hasData && ( <> {/* Totals */}
Download ({range})
{formatBytes(totalDownload)}
Upload ({range})
{formatBytes(totalUpload)}
{/* Throughput chart */}

Network Traffic

formatBytes(v)} tick={{ fontSize: 11 }} width={70} stroke="currentColor" /> { try { return format(parseISO(String(t)), 'MMM d, HH:mm'); } catch { return String(t); } }} formatter={(value: number, name: string) => [formatBytes(value), name === 'download' ? 'Download' : 'Upload']} /> (v === 'download' ? 'Download' : 'Upload')} />
{/* 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 */}

Top Talkers

{topClients.length === 0 ?
No client traffic recorded.
: (
{topClients.map(c => { const isReal = c.mac !== 'unknown' && c.mac !== 'other'; return (
isReal && navigate(`/clients/${encodeURIComponent(c.mac)}`)} className={clsx('px-5 py-2', isReal && 'cursor-pointer hover:bg-blue-50 dark:hover:bg-slate-700/40 transition-colors')}>
{clientLabel(c)} {isReal && ( {c.ip_address || c.mac} )}
{formatBytes(c.total_bytes)}
{formatBytes(c.download_bytes)} {formatBytes(c.upload_bytes)}
); })}
)}
{/* Right column: protocol breakdown + data health, stacked to align with Top Talkers */}
{/* App categories */}

Traffic by Application

{apps.length === 0 ?
No application data recorded.
: (
{/* 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)}
))}
)}
{/* 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'}
))}
)}
)}
); }