From 59931f402a350227e95025256cced61b1b1efbac Mon Sep 17 00:00:00 2001 From: Janez T Date: Fri, 3 Apr 2026 22:19:23 +0200 Subject: [PATCH] feat: Add traffic over time chart --- .../components/dashboard/dashboard-shell.tsx | 209 ++++++++++++++++-- 1 file changed, 189 insertions(+), 20 deletions(-) diff --git a/worker/src/components/dashboard/dashboard-shell.tsx b/worker/src/components/dashboard/dashboard-shell.tsx index c6eadc5..1845cbe 100644 --- a/worker/src/components/dashboard/dashboard-shell.tsx +++ b/worker/src/components/dashboard/dashboard-shell.tsx @@ -224,32 +224,14 @@ export function DashboardShell() { - Traffic Trend + Traffic Over Time Packets per {summary?.filter.bucket ?? "time"} bucket {isLoading && !summary ? ( ) : summary?.chartPoints.length ? ( -
- {summary.chartPoints.map((point) => { - const pct = Math.max((point.totalPackets / maxTrend) * 100, 3); - return ( -
- - {point.totalPackets} - -
- - {point.label.slice(5)} - -
- ); - })} -
+ ) : ( )} @@ -369,6 +351,193 @@ export function DashboardShell() { ); } +const CHART_H = 240; +const CHART_PAD = { top: 20, right: 16, bottom: 32, left: 48 }; + +function TrafficChart({ points, maxValue }: { points: ChartPoint[]; maxValue: number }) { + const [hover, setHover] = useState(null); + const count = points.length; + if (count === 0) return null; + + const innerW = 100; // we use viewBox percentages + const innerH = CHART_H - CHART_PAD.top - CHART_PAD.bottom; + + // find peak + const peakIdx = points.reduce((best, p, i) => (p.totalPackets > points[best].totalPackets ? i : best), 0); + + // Y axis grid: 4 nice lines + const gridLines = niceGridLines(maxValue, 4); + + // point positions (normalized 0-1) + const xs = points.map((_, i) => i / Math.max(count - 1, 1)); + const ys = points.map((p) => 1 - p.totalPackets / maxValue); + + // SVG path for the line + const linePath = xs.map((x, i) => `${i === 0 ? "M" : "L"}${x * innerW},${ys[i] * innerH}`).join(" "); + // area path (closed to bottom) + const areaPath = `${linePath} L${xs[xs.length - 1] * innerW},${innerH} L0,${innerH} Z`; + + // X-axis labels: show ~6 evenly spaced + const labelStep = Math.max(1, Math.floor(count / 6)); + const xLabels = points + .map((p, i) => ({ i, label: p.label.slice(5) })) + .filter((_, i) => i % labelStep === 0 || i === count - 1); + + return ( +
+ setHover(null)} + > + + + + + + + + + + + + {/* Y grid lines */} + {gridLines.map((val) => { + const y = (1 - val / maxValue) * innerH; + return ( + + + + {formatCompact(val)} + + + ); + })} + + {/* baseline */} + + + {/* area fill */} + + + {/* line */} + + + {/* peak dot */} + + + {/* peak label */} + + {points[peakIdx].totalPackets.toLocaleString()} + + + {/* X-axis labels */} + {xLabels.map(({ i, label }) => ( + + {label} + + ))} + + {/* invisible hover zones */} + {points.map((point, i) => { + const sliceW = innerW / count; + return ( + setHover(i)} + /> + ); + })} + + {/* hover indicator */} + {hover !== null && ( + <> + + + + )} + + + {/* hover tooltip */} + {hover !== null && ( +
+
{points[hover].totalPackets.toLocaleString()} packets
+
{points[hover].label}
+
+ )} +
+ ); +} + +function niceGridLines(max: number, count: number): number[] { + if (max <= 0) return [0]; + const rough = max / count; + const magnitude = 10 ** Math.floor(Math.log10(rough)); + const residual = rough / magnitude; + const nice = residual <= 1.5 ? 1 : residual <= 3 ? 2 : residual <= 7 ? 5 : 10; + const step = nice * magnitude; + const lines: number[] = []; + for (let v = step; v <= max; v += step) { + lines.push(Math.round(v)); + } + return lines; +} + +function formatCompact(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(n >= 10_000 ? 0 : 1)}K`; + return String(n); +} + function MetricCard({ label, value, note }: { label: string; value: number | string; note: string }) { return (