From df9f4a07ada314e383bf0ed4acdfe022cf6fc8bc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 13:34:11 -0400 Subject: [PATCH 001/163] stats: node-types donut, multi-IATA filtering, preset split - node-types: new /stats/node-types endpoint + useNodeTypes; donutOption replaces the "Coming soon" card with a live donut - stats endpoints take iatas[] instead of a single iata, so multi-IATA regions filter instead of falling back to all (drops useStatsIata) - radio presets keep the node/observer split via stacked presetBarsOption - drop client-side telemetry ms-normalization (backend now emits epoch ms on both paths); charts pick delta-vs-raw counters off the response interval - MeshTab: range-driven charts lead, all-time charts follow; KPI window from overview.windowHours instead of a hardcoded 24h - ci: docker-publish builds a :dev image on dev-branch pushes --- .github/workflows/docker-publish.yml | 3 +- src/api/client.ts | 32 +++-- src/features/stats/MeshTab.tsx | 49 ++++--- src/features/stats/ObserverTab.tsx | 6 +- src/features/stats/chartOptions.ts | 136 ++++++++++++++++-- src/features/stats/echarts-setup.ts | 2 + src/features/stats/transforms.ts | 17 +-- src/features/stats/types.ts | 8 +- src/features/stats/useLiveStats.ts | 1 + src/features/stats/useStats.ts | 44 +++--- src/features/stats/useTelemetry.ts | 11 +- tests/api/client.test.ts | 35 ++++- tests/features/stats/chart-options.test.ts | 100 ++++++++++++- tests/features/stats/radio-presets.test.ts | 16 ++- .../stats/telemetry-normalize.test.ts | 38 ----- 15 files changed, 369 insertions(+), 129 deletions(-) delete mode 100644 tests/features/stats/telemetry-normalize.test.ts diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 64087af6..22446c18 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and Publish Docker Image on: push: - branches: [main] + branches: [main, dev] tags: ["v*"] env: @@ -35,6 +35,7 @@ jobs: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} tags: | type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha diff --git a/src/api/client.ts b/src/api/client.ts index a30c3998..96c80877 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -12,6 +12,7 @@ import type { RadioPreset, ScopeStats, ObserverTelemetry, + NodeTypeCount, } from "../features/stats/types"; // typed fetch wrapper with query params @@ -253,31 +254,34 @@ export function getNodeNeighbors(nodeId: string): Promise { return request(`/nodes/${nodeId}/neighbors`); } -// stats endpoints. `iata` is a single code (undefined = all regions); the /stats/* endpoints filter -// by one IATA only, unlike the comma-separated `iatas` used elsewhere. +// stats endpoints -export function getStatsOverview(iata?: string): Promise { - return request("/stats/overview", { iata }); +export function getStatsOverview(iatas?: string[]): Promise { + return request("/stats/overview", { iatas: iatasParam(iatas) }); } -export function getStatsObservations(iata?: string, since?: number): Promise { - return request("/stats/observations", { iata, since }); +export function getStatsObservations(iatas?: string[], since?: number): Promise { + return request("/stats/observations", { iatas: iatasParam(iatas), since }); } -export function getPayloadBreakdown(iata?: string, since?: number): Promise { - return request("/stats/payload-breakdown", { iata, since }); +export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { + return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since }); } -export function getTopNodes(iata?: string, limit = 10): Promise { - return request("/stats/top-nodes", { iata, limit }); +export function getTopNodes(iatas?: string[], limit = 10): Promise { + return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit }); } -export function getTopObservers(iata?: string, since?: number, limit = 10): Promise { - return request("/stats/top-observers", { iata, since, limit }); +export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit }); } -export function getRadioPresets(iata?: string): Promise { - return request("/stats/radio-presets", { iata }); +export function getRadioPresets(iatas?: string[]): Promise { + return request("/stats/radio-presets", { iatas: iatasParam(iatas) }); +} + +export function getStatsNodeTypes(iatas?: string[]): Promise { + return request("/stats/node-types", { iatas: iatasParam(iatas) }); } // renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 5b4440c0..f2ec64fd 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,8 +1,8 @@ import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, type ChartColors } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes } from "./useStats"; -import { observationsAreaOption, leaderboardOption, typeBarOption } from "./chartOptions"; +import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; import { aggregatePresets, formatPreset } from "./transforms"; @@ -49,6 +49,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const topObservers = useTopObservers(range, 8); const radioPresets = useRadioPresets(); const scopes = useScopes(); + const nodeTypes = useNodeTypes(); const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]); const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]); @@ -90,11 +91,21 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [observerIds, onSelectObserver], ); + const typeRows = useMemo( + () => + [...(nodeTypes.data ?? [])] + .sort((a, b) => b.count - a.count) + .map((t) => ({ name: t.nodeTypeName, value: t.count, color: nodeTypeColor(t.nodeTypeName, colors) })), + [nodeTypes.data, colors], + ); + const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]); + const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]); + const presetRows = useMemo( - () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })), - [radioPresets.data, colors], + () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), + [radioPresets.data], ); - const presetsOption = useMemo(() => leaderboardOption(presetRows, colors, 150), [presetRows, colors]); + const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [presetRows, colors]); const scopeRows = useMemo( () => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount), @@ -106,14 +117,16 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const ov = overview.data; const kpiLoading = overview.isLoading; + // top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below + const ovWindow = `${ov?.windowHours ?? 24}h`; return (
- - - - + + + +
- + {/* range-driven charts lead the grid; the all-time ones follow below */} + Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> Payload types · {range}} right={{formatCount(payloadTotal)} obs} @@ -136,17 +150,12 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isError={payload.isError} isEmpty={payloadItems.length === 0} /> - Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> - {/* needs a /stats/node-types endpoint (ticket filed) — the old donut counted types among - the top-10 nodes only, which read as the region's whole population */} - -
- Coming soon -
-
- + {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} + + + - Scopes · all regions}> + Scopes · all regions · all time}> {scopes.isError ? (
Failed to load
) : scopes.isLoading ? ( diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index dd8823c1..05ccc541 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -175,11 +175,13 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan }, [selectedObserverId, topObservers.data, onSelectObserver]); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); - const airtime = useMemo(() => airtimeOption(points, colors), [points, colors]); + // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points + const bucketed = telemetry.data != null && telemetry.data.interval !== "1h"; + const airtime = useMemo(() => airtimeOption(points, colors, bucketed), [points, colors, bucketed]); const battery = useMemo(() => batteryOption(points, colors), [points, colors]); const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); const queue = useMemo(() => queueOption(points, colors), [points, colors]); - const recvErrors = useMemo(() => receiveErrorsOption(points, colors), [points, colors]); + const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]); // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 284fc943..8cd36b6c 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -131,6 +131,122 @@ export function leaderboardOption( }; } +// Horizontal stacked bars per preset (node + observer segments, total at the bar end). +export function presetBarsOption( + rows: { name: string; nodes: number; observers: number }[], + c: ChartColors, + gridLeft = 172, // fits a full "910.525 · 62.5k · SF7" label +): EChartsOption { + const totals = rows.map((r) => r.nodes + r.observers); + const segment = (data: number[], color: string) => ({ + type: "bar" as const, + stack: "preset", + barMaxWidth: 22, + barCategoryGap: "42%", + data, + itemStyle: { color }, + }); + return { + animation: false, + backgroundColor: "transparent", + grid: { left: gridLeft, right: 56, top: 22, bottom: 6 }, + tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "shadow" } }, + legend: { + data: ["Nodes", "Observers"], + right: 8, + top: 0, + itemWidth: 10, + itemHeight: 10, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + xAxis: { type: "value", axisLabel: { show: false }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false } }, + yAxis: { + type: "category", + inverse: true, + data: rows.map((r) => r.name), + axisLine: { show: false }, + axisTick: { show: false }, + axisLabel: { + color: c.textNormal, + fontFamily: MONO, + fontSize: 11, + align: "left", + margin: gridLeft - 10, + width: gridLeft - 16, + overflow: "truncate", + }, + }, + series: [ + { name: "Nodes", ...segment(rows.map((r) => r.nodes), c.primary) }, + { + name: "Observers", + ...segment(rows.map((r) => r.observers), c.secondary), + // outer segment carries the row total so it sits at the end of the whole stack + label: { + show: true, + position: "right" as const, + color: c.textBright, + fontFamily: MONO, + fontSize: 11, + formatter: (p: { dataIndex: number }) => totals[p.dataIndex]!.toLocaleString(), + }, + }, + ], + }; +} + +// Donut for small category sets; the center total rides on the first slice's label, which ECharts pins to the ring center. +export function donutOption( + items: { name: string; value: number; color?: string }[], + c: ChartColors, + centerValue: string, + centerLabel: string, +): EChartsOption { + const centerText = { + show: true, + position: "center" as const, + formatter: `{v|${centerValue}}\n{l|${centerLabel}}`, + rich: { + v: { color: c.textBright, fontFamily: MONO, fontSize: 21, fontWeight: 700 as const, lineHeight: 24 }, + l: { color: c.textMuted, fontFamily: MONO, fontSize: 9, lineHeight: 12 }, + }, + }; + return { + animation: false, + backgroundColor: "transparent", + tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c} ({d}%)" }, + legend: { + orient: "horizontal", + left: "center", + bottom: 4, + itemWidth: 9, + itemHeight: 9, + itemGap: 10, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + series: [ + { + type: "pie", + radius: ["48%", "70%"], + center: ["50%", "46%"], + avoidLabelOverlap: false, + itemStyle: { borderColor: c.bgSurface, borderWidth: 2, borderRadius: 4 }, + label: { show: false }, + emphasis: { scaleSize: 5 }, + data: items.map((it, i) => ({ + name: it.name, + value: it.value, + itemStyle: { color: it.color ?? c.series[i % c.series.length] }, + // the center total rides on the first slice only; per-slice labels stay hidden + ...(i === 0 ? { label: centerText, emphasis: { label: centerText } } : {}), + })), + }, + ], + }; +} + // Vertical bars for the payload-type breakdown. Replaced the old donut: with 10+ slivers the legend // needed scrolling, names truncated, and thin slices couldn't be compared by eye — bars label every // category inline and need no legend at all. @@ -172,11 +288,10 @@ export function typeBarOption( } // ---- Observer telemetry ---- -// `t` arrives in epoch ms (normalized in useObserverTelemetry). +// `t` arrives in epoch ms. -// airtimeTx/RxPct are cumulative counters, so chart the per-report delta (airtime used per interval), -// clamped at 0 to ignore counter resets. Caveat: under bucketing (7d/30d) the backend AVGs these -// counters, so the delta is approximate — pending a backend MAX−MIN fix (beacon-docs ticket). +// 1h points are cumulative counters, so chart per-report deltas (clamped at 0 for resets); +// bucketed points already arrive as per-bucket deltas, so chart those as-is. function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxPct") { const out: [number, number | null][] = []; for (let i = 1; i < points.length; i++) { @@ -188,7 +303,9 @@ function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxP return out; } -export function airtimeOption(points: TelemetryPoint[], c: ChartColors): EChartsOption { +export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketed: boolean): EChartsOption { + const series = (key: "airtimeRxPct" | "airtimeTxPct") => + bucketed ? points.map((p) => [p.t, p[key]]) : deltaSeries(points, key); return { animation: false, backgroundColor: "transparent", @@ -198,8 +315,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors): ECharts xAxis: timeAxis(c), yAxis: valueAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, ], }; } @@ -254,5 +371,6 @@ export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => export const queueOption = (p: TelemetryPoint[], c: ChartColors) => metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); -export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Recv errors / report", color: c.danger, accessor: (x) => x.receiveErrors, delta: true, area: true }); +// receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => + metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index e703520a..319b6f51 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -6,6 +6,7 @@ import * as echarts from "echarts/core"; import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts"; import { GridComponent, + TitleComponent, TooltipComponent, LegendComponent, GraphicComponent, @@ -20,6 +21,7 @@ echarts.use([ PieChart, GaugeChart, GridComponent, + TitleComponent, TooltipComponent, LegendComponent, GraphicComponent, diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index 211f8e92..3b9f6410 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -1,17 +1,18 @@ import type { RadioPreset, TelemetryPoint } from "./types"; -// Collapse radio presets (one row per preset+iata+sourceType) into one row per preset, summing -// counts, sorted by descending total. Junk presets (all-zero "0,0,0" from unconfigured radios) are -// dropped so they don't clutter the chart. -export function aggregatePresets(rows: RadioPreset[]): { preset: string; value: number }[] { - const byPreset = new Map(); +// Collapse presets to one row each (keeping the node/observer split), dropping junk "0,0,0" configs. +export function aggregatePresets(rows: RadioPreset[]): { preset: string; nodes: number; observers: number }[] { + const byPreset = new Map(); for (const r of rows) { if (isJunkPreset(r.preset)) continue; - byPreset.set(r.preset, (byPreset.get(r.preset) ?? 0) + r.count); + const cur = byPreset.get(r.preset) ?? { nodes: 0, observers: 0 }; + if (r.sourceType === "node") cur.nodes += r.count; + else cur.observers += r.count; + byPreset.set(r.preset, cur); } return [...byPreset.entries()] - .map(([preset, value]) => ({ preset, value })) - .sort((a, b) => b.value - a.value); + .map(([preset, counts]) => ({ preset, ...counts })) + .sort((a, b) => b.nodes + b.observers - (a.nodes + a.observers)); } function isJunkPreset(preset: string): boolean { diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 8274b0cd..d09650cf 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -47,6 +47,12 @@ export interface RadioPreset { count: number; } +export interface NodeTypeCount { + nodeType: number; + nodeTypeName: string; + count: number; +} + export interface ScopeStats { name: string; // normalized scope name e.g. "#bc" packetCount: number; @@ -55,7 +61,7 @@ export interface ScopeStats { } export interface TelemetryPoint { - t: number; // epoch ms (normalized in useObserverTelemetry — backend raw path emits seconds) + t: number; // epoch ms batteryMv: number | null; airtimeTxPct: number | null; airtimeRxPct: number | null; diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts index 49ebda00..50624d36 100644 --- a/src/features/stats/useLiveStats.ts +++ b/src/features/stats/useLiveStats.ts @@ -9,6 +9,7 @@ import type { StatsOverview, StatsRange } from "./types"; // Live overview KPIs: every packetObservation bumps the cached overview counters (no refetch). High // frequency, so increments are coalesced and flushed once per animation frame. The overview query also // refetches periodically (useStatsOverview) so the live deltas self-correct against the server. +// both totalPackets and totalObservations feed the top KPIs, so both bumps count. export function useLiveOverview(wsManager: WsManager) { const { regionKey } = useRegion(); const qc = useQueryClient(); diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index e4f09aaf..836f3db2 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -8,6 +8,7 @@ import { getTopObservers, getRadioPresets, getStatsScopes, + getStatsNodeTypes, } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; @@ -21,18 +22,11 @@ const common = { // `since` is computed inside queryFn so refetches use a fresh window without churning the query key. const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range]; -// The /stats/* endpoints filter by a single IATA. Map the region selection to one: a single selected -// IATA filters; "all regions" or a multi-IATA region passes nothing (the endpoints then span all). -function useStatsIata(): { iata: string | undefined; regionKey: string } { - const { iatas, regionKey } = useRegion(); - return { iata: iatas?.length === 1 ? iatas[0] : undefined, regionKey }; -} - export function useStatsOverview() { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-overview", regionKey], - queryFn: () => getStatsOverview(iata), + queryFn: () => getStatsOverview(iatas), ...common, // self-correct the WS-accumulated live counters against the server refetchInterval: 60_000, @@ -40,46 +34,58 @@ export function useStatsOverview() { } export function useStatsObservations(range: StatsRange) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-observations", regionKey, range], - queryFn: () => getStatsObservations(iata, sinceFor(range)), + queryFn: () => getStatsObservations(iatas, sinceFor(range)), ...common, + // feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh + refetchInterval: 60_000, }); } export function usePayloadBreakdown(range: StatsRange) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-payload", regionKey, range], - queryFn: () => getPayloadBreakdown(iata, sinceFor(range)), + queryFn: () => getPayloadBreakdown(iatas, sinceFor(range)), ...common, }); } export function useTopNodes(limit = 10) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-top-nodes", regionKey, limit], - queryFn: () => getTopNodes(iata, limit), + queryFn: () => getTopNodes(iatas, limit), ...common, }); } export function useTopObservers(range: StatsRange, limit = 10) { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-top-observers", regionKey, range, limit], - queryFn: () => getTopObservers(iata, sinceFor(range), limit), + queryFn: () => getTopObservers(iatas, sinceFor(range), limit), ...common, }); } export function useRadioPresets() { - const { iata, regionKey } = useStatsIata(); + const { iatas, regionKey } = useRegion(); return useQuery({ queryKey: ["stats-radio-presets", regionKey], - queryFn: () => getRadioPresets(iata), + queryFn: () => getRadioPresets(iatas), + ...common, + }); +} + +// node-types is a population census (no time window), so the key is region-only +export function useNodeTypes() { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-node-types", regionKey], + queryFn: () => getStatsNodeTypes(iatas), ...common, }); } diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 158f833a..f2ec34c5 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,6 +1,6 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { getObserver, getObserverTelemetry } from "../../api/client"; -import type { ObserverTelemetry, StatsRange } from "./types"; +import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. const RANGE_PARAM: Record = { @@ -17,13 +17,6 @@ const INTERVAL_PARAM: Record = { "30d": "24h", }; -// The backend's raw (interval=1h) path emits `t` in epoch SECONDS while the bucketed path emits ms. -// Normalize everything to ms here so chart code is unit-agnostic. (Tracked: beacon-docs ticket.) -export function normalizeTelemetry(data: ObserverTelemetry, interval: string): ObserverTelemetry { - if (interval !== "1h") return data; - return { ...data, points: data.points.map((p) => ({ ...p, t: p.t * 1000 })) }; -} - export function useObserver(observerId: string | null) { return useQuery({ queryKey: ["observer", observerId], @@ -38,7 +31,7 @@ export function useObserverTelemetry(observerId: string | null, range: StatsRang const interval = INTERVAL_PARAM[range]; return useQuery({ queryKey: ["observer-telemetry", observerId, range, interval], - queryFn: async () => normalizeTelemetry(await getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), interval), + queryFn: () => getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), enabled: !!observerId, staleTime: 30_000, placeholderData: keepPreviousData, diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index a328fdef..a9909e97 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail } from "../../src/api/client"; +import { getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -331,3 +331,36 @@ describe("getObserversPage", () => { expect(url).toContain("name=north"); }); }); + +describe("stats endpoints", () => { + it("joins the region's IATAs into the iatas param", async () => { + const getUrl = mockFetchOnce({ totalPackets: 0 }); + + await getStatsOverview(["YOW", "YYZ"]); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/overview"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + }); + + it("omits iatas for all regions and still forwards the rest", async () => { + const getUrl = mockFetchOnce([]); + + await getTopObservers(undefined, 1700000000000, 15); + + const url = new URL(getUrl()); + expect(url.searchParams.has("iatas")).toBe(false); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("15"); + }); + + it("hits /stats/node-types with the region's IATAs", async () => { + const getUrl = mockFetchOnce([{ nodeType: 2, nodeTypeName: "repeater", count: 12 }]); + + await getStatsNodeTypes(["YOW", "YYZ"]); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/node-types"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + }); +}); diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index 031100e1..a8d189ed 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -1,7 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- poking into loose ECharts option shapes */ import { describe, it, expect } from "vitest"; -import { typeBarOption, leaderboardOption } from "../../../src/features/stats/chartOptions"; +import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption } from "../../../src/features/stats/chartOptions"; import type { ChartColors } from "../../../src/features/stats/chartTheme"; +import type { TelemetryPoint } from "../../../src/features/stats/types"; const colors: ChartColors = { primary: "#3b82f6", @@ -51,6 +52,53 @@ describe("typeBarOption", () => { }); }); +describe("donutOption", () => { + it("pins the total to the ring center via a label on the first slice, not a title block", () => { + const opt = donutOption([{ name: "repeater", value: 3 }, { name: "sensor", value: 7 }], colors, "10", "NODES") as Record; + // title/graphic blocks never sat quite right — the pie's own center label always does + expect(opt.title).toBeUndefined(); + expect(opt.graphic).toBeUndefined(); + const label = opt.series[0].data[0].label; + expect(label.show).toBe(true); + expect(label.position).toBe("center"); + expect(label.formatter).toContain("10"); + expect(label.formatter).toContain("NODES"); + // only the first slice carries it, or every slice would stamp its own copy + expect(opt.series[0].data[1].label).toBeUndefined(); + }); + + it("centers the pie with the legend below so the card fills evenly", () => { + const opt = donutOption([{ name: "repeater", value: 3 }], colors, "3", "NODES") as Record; + expect(opt.series[0].center[0]).toBe("50%"); + expect(opt.legend.left).toBe("center"); + expect(opt.legend.bottom).toBeDefined(); + }); +}); + +describe("presetBarsOption", () => { + const rows = [ + { name: "910.525 · 62.5k · SF7", nodes: 112, observers: 46 }, + { name: "910.425 · 62.5k · SF7", nodes: 5, observers: 1 }, + ]; + + it("stacks a node and an observer series per preset, in row order", () => { + const opt = presetBarsOption(rows, colors) as Record; + expect(opt.yAxis.data).toEqual(["910.525 · 62.5k · SF7", "910.425 · 62.5k · SF7"]); + expect(opt.series.map((s: { name: string }) => s.name)).toEqual(["Nodes", "Observers"]); + expect(opt.series[0].stack).toBe(opt.series[1].stack); + expect(opt.series[0].data).toEqual([112, 5]); + expect(opt.series[1].data).toEqual([46, 1]); + }); + + it("labels each stack with its total at the bar end", () => { + const opt = presetBarsOption(rows, colors) as Record; + const label = opt.series[1].label; + expect(label.show).toBe(true); + expect(label.formatter({ dataIndex: 0 })).toBe("158"); + expect(label.formatter({ dataIndex: 1 })).toBe("6"); + }); +}); + describe("leaderboardOption", () => { it("left-aligns names at the card edge and truncates long ones to the label gutter", () => { const rows = [{ name: "A very long observer name that overflows", value: 5, color: "#abc" }]; @@ -61,3 +109,53 @@ describe("leaderboardOption", () => { expect(opt.yAxis.axisLabel.margin).toBe(110); }); }); + +const point = (t: number, p: Partial): TelemetryPoint => ({ + t, + batteryMv: null, + airtimeTxPct: null, + airtimeRxPct: null, + noiseFloorDb: null, + uptimeSeconds: null, + queueLength: null, + receiveErrors: null, + ...p, +}); + +describe("airtimeOption", () => { + const points = [ + point(1000, { airtimeRxPct: 10, airtimeTxPct: 4 }), + point(2000, { airtimeRxPct: 12, airtimeTxPct: 4 }), + point(3000, { airtimeRxPct: 11, airtimeTxPct: 7 }), + ]; + + it("charts raw counters as clamped per-report deltas", () => { + const opt = airtimeOption(points, colors, false) as Record; + expect(opt.series[0].data).toEqual([[2000, 2], [3000, 0]]); // RX dips → clamp at 0 + expect(opt.series[1].data).toEqual([[2000, 0], [3000, 3]]); // TX + }); + + it("charts bucketed points as-is", () => { + const opt = airtimeOption(points, colors, true) as Record; + expect(opt.series[0].data).toEqual([[1000, 10], [2000, 12], [3000, 11]]); + expect(opt.series[1].data).toEqual([[1000, 4], [2000, 4], [3000, 7]]); + }); +}); + +describe("receiveErrorsOption", () => { + const points = [ + point(1000, { receiveErrors: 5 }), + point(2000, { receiveErrors: 8 }), + point(3000, { receiveErrors: 8 }), + ]; + + it("charts raw counters as clamped per-report deltas", () => { + const opt = receiveErrorsOption(points, colors, false) as Record; + expect(opt.series[0].data).toEqual([[2000, 3], [3000, 0]]); + }); + + it("charts bucketed points as-is", () => { + const opt = receiveErrorsOption(points, colors, true) as Record; + expect(opt.series[0].data).toEqual([[1000, 5], [2000, 8], [3000, 8]]); + }); +}); diff --git a/tests/features/stats/radio-presets.test.ts b/tests/features/stats/radio-presets.test.ts index 4f11d264..92b5e9ce 100644 --- a/tests/features/stats/radio-presets.test.ts +++ b/tests/features/stats/radio-presets.test.ts @@ -5,7 +5,7 @@ import type { RadioPreset } from "../../../src/features/stats/types"; const row = (preset: string, sourceType: string, iata: string, count: number): RadioPreset => ({ preset, sourceType, iata, count }); describe("aggregatePresets", () => { - it("sums counts for the same preset across sourceType and iata", () => { + it("splits each preset into node and observer totals across iatas", () => { const rows = [ row("910.525,62.5,7", "observer", "YVR", 3), row("910.525,62.5,7", "node", "YVR", 5), @@ -13,13 +13,17 @@ describe("aggregatePresets", () => { row("869.525,250,11", "node", "YVR", 4), ]; const out = aggregatePresets(rows); - const byPreset = Object.fromEntries(out.map((r) => [r.preset, r.value])); - expect(byPreset["910.525,62.5,7"]).toBe(10); - expect(byPreset["869.525,250,11"]).toBe(4); + expect(out).toContainEqual({ preset: "910.525,62.5,7", nodes: 5, observers: 5 }); + expect(out).toContainEqual({ preset: "869.525,250,11", nodes: 4, observers: 0 }); }); - it("returns rows sorted by descending count", () => { - const rows = [row("910.5,62.5,7", "node", "YVR", 1), row("868,250,11", "node", "YVR", 9), row("915,125,9", "node", "YVR", 5)]; + it("returns rows sorted by descending total", () => { + const rows = [ + row("910.5,62.5,7", "node", "YVR", 1), + row("868,250,11", "node", "YVR", 5), + row("868,250,11", "observer", "YVR", 4), + row("915,125,9", "node", "YVR", 5), + ]; expect(aggregatePresets(rows).map((r) => r.preset)).toEqual(["868,250,11", "915,125,9", "910.5,62.5,7"]); }); diff --git a/tests/features/stats/telemetry-normalize.test.ts b/tests/features/stats/telemetry-normalize.test.ts deleted file mode 100644 index 1c7fced0..00000000 --- a/tests/features/stats/telemetry-normalize.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { normalizeTelemetry } from "../../../src/features/stats/useTelemetry"; -import type { ObserverTelemetry } from "../../../src/features/stats/types"; - -const SEC = 1_700_000_000; // a second-scale epoch -const MS = SEC * 1000; - -describe("normalizeTelemetry", () => { - it("scales raw (1h) second-epoch points up to ms", () => { - const raw: ObserverTelemetry = { - range: "24h", - interval: "1h", - points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }], - }; - expect(normalizeTelemetry(raw, "1h").points[0]!.t).toBe(MS); - }); - - it("leaves bucketed (6h/24h) ms-epoch points untouched", () => { - const bucketed: ObserverTelemetry = { - range: "7d", - interval: "6h", - points: [{ t: MS, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }], - }; - expect(normalizeTelemetry(bucketed, "6h").points[0]!.t).toBe(MS); - }); - - it("does not mutate other fields", () => { - const raw: ObserverTelemetry = { - range: "24h", - interval: "1h", - points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: 1.5, airtimeRxPct: 2.5, noiseFloorDb: -110, uptimeSeconds: 42, queueLength: 3, receiveErrors: 1 }], - }; - const p = normalizeTelemetry(raw, "1h").points[0]!; - expect(p.batteryMv).toBe(3700); - expect(p.airtimeTxPct).toBe(1.5); - expect(p.receiveErrors).toBe(1); - }); -}); From ad7b4859132011cc006e211f9f391f1db1391657 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 13 Jun 2026 23:51:09 -0400 Subject: [PATCH 002/163] feat: traces vs pings feat: SNR for trace path in list feat: known niehgbour count --- src/api/client.ts | 5 +- src/features/nodes/NodeDetailPanel.tsx | 8 ++- src/features/nodes/NodeTable.tsx | 7 ++ src/features/nodes/node-updates.ts | 3 + src/features/nodes/types.ts | 2 + src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 30 ++------- src/features/traces/TraceList.tsx | 83 ++++++++++++++++++++---- src/types/api.ts | 6 ++ tests/features/traces/TraceList.test.tsx | 31 ++++++++- 10 files changed, 134 insertions(+), 45 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 96c80877..c085d8a0 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,5 +1,5 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; -import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceDetail } from "../types/api"; +import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; @@ -166,11 +166,12 @@ export function searchCrossIATARoutes( // the last item's lastHeardAt); /traces/{tag} returns the tag's packets with resolved routes. export function getTraces( iatas: string[] | undefined, - params?: { scope?: string; since?: number; until?: number; cursor?: number; limit?: number }, + params?: { scope?: string; type?: TraceType; since?: number; until?: number; cursor?: number; limit?: number }, ): Promise { return request("/traces", { iatas: iatasParam(iatas), scope: params?.scope, + type: params?.type, // TRACE or PING; omitted = both (request() drops undefined params) since: params?.since, until: params?.until, cursor: params?.cursor, diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 1af26652..91673c16 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -21,8 +21,10 @@ function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClic {neighbor.iata}
-
- {neighbor.observationCount.toLocaleString()} obs +
+ {neighbor.publicKey} + · + {neighbor.observationCount.toLocaleString()} obs
); @@ -155,7 +157,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
-
+
0 ? `Neighbors (${node.knownNeighborCount})` : "Neighbors"}> {neighbors && neighbors.length > 0 ? (
{neighbors.map((n) => ( diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 6bbc983c..35f0a77d 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -72,6 +72,12 @@ const COLUMNS: Column[] = [ — ), }, + { + header: "Neighbors", + className: "text-text-muted", + sortValue: (node) => node.knownNeighborCount, + cell: (node) => node.knownNeighborCount.toLocaleString(), + }, { header: "Location", className: "text-text-muted", @@ -105,6 +111,7 @@ function renderNodeCard(node: NodeSummary) {
{formatRadio(node.radio) ?? "—"} {location && · {location}} + {node.knownNeighborCount > 0 && · {node.knownNeighborCount.toLocaleString()} neighbors}
{node.iatas && node.iatas.length > 0 && (
diff --git a/src/features/nodes/node-updates.ts b/src/features/nodes/node-updates.ts index ac3c23c8..caee5902 100644 --- a/src/features/nodes/node-updates.ts +++ b/src/features/nodes/node-updates.ts @@ -50,6 +50,9 @@ export function upsertNodePages( radio: data.radio, defaultScope: data.defaultScope, iatas: data.iatas, + // the nodeUpdate event rides on an advert and doesn't carry a neighbor count; a node we're + // meeting for the first time has none resolved yet, so start at 0 until a reload fills it in + knownNeighborCount: 0, isObserver: data.isObserver, }; const pages = [...old.pages]; diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 7d662297..2c24ff2f 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -14,6 +14,7 @@ export interface NodeSummary { radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown defaultScope?: string; // most recently matched transport scope name, e.g. "#bc" iatas: NodeIATA[]; + knownNeighborCount: number; // distinct first-hop neighbors we've resolved for this node // Set when this node also runs as an observer (watches traffic for uplink). isObserver drives the // map's observer-pip marker variant; observerId, when present, links to that observer's detail. isObserver?: boolean; @@ -35,6 +36,7 @@ export interface Node extends NodeSummary { export interface NodeNeighbor { id: string; name?: string; + publicKey: string; // hex-encoded prefix nodeType: number; nodeTypeName: string; lat?: number; diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 8e1fa7dc..bd78f3f3 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -16,7 +16,7 @@ interface StatsOverviewProps { wsManager: WsManager; } -// Stats page shell: a sub-header bar (Mesh / Observer pills + range + live dot) over the active +// Stats page shell: a sub-header bar (Mesh / Observer pills + range) over the active // sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. export function StatsOverview({ wsManager }: StatsOverviewProps) { @@ -48,7 +48,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { return (
- +
{tab === "mesh" ? ( diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index a5793e15..0d2840b1 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -1,5 +1,3 @@ -import type { WsManager } from "../../api/ws-manager"; -import { useWsStatus } from "../../hooks/useWsStatus"; import { Segmented } from "./Segmented"; import type { StatsRange, StatsTab } from "./types"; @@ -39,17 +37,9 @@ interface Props { onTabChange: (tab: StatsTab) => void; range: StatsRange; onRangeChange: (range: StatsRange) => void; - wsManager: WsManager; } -export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, wsManager }: Props) { - const { status } = useWsStatus(wsManager); - const live = status === "connected"; - const connecting = status === "connecting"; - const dotColor = live ? "bg-green" : connecting ? "bg-warn" : "bg-text-dim"; - const label = live ? "LIVE" : connecting ? "LIVE" : "OFFLINE"; - const labelColor = live ? "text-green" : connecting ? "text-warn" : "text-text-dim"; - +export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { return (
-
- onRangeChange(v as StatsRange)} - ariaLabel="Time range" - /> -
- - {label} -
-
+ onRangeChange(v as StatsRange)} + ariaLabel="Time range" + />
); } diff --git a/src/features/traces/TraceList.tsx b/src/features/traces/TraceList.tsx index d815e433..38f49a36 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -5,18 +5,58 @@ import { useRegion } from "../../hooks/useRegion"; import { SkeletonRows } from "../../components/SkeletonRows"; import { EmptyState } from "../../components/EmptyState"; import { Timestamp } from "../../components/Timestamp"; +import { Badge } from "../../components/Badge"; +import { Segmented } from "../stats/Segmented"; +import { snrLevel, SIGNAL_LEVEL_CLASSES, formatSnr } from "../../lib/formatters"; import { TraceDetailPanel } from "./TraceDetailPanel"; -import type { TraceTagSummary } from "../../types/api"; +import type { TraceTagSummary, TraceType } from "../../types/api"; // Traces are modest in number and the list isn't streamed, so a single region-filtered fetch covers // the card list (the /traces cursor is sound if pagination is ever needed). const TRACE_LIST_LIMIT = 200; +// "" = both; the backend takes TRACE or PING and omits the param to mean all. +const TYPE_OPTIONS = [ + { value: "", label: "All" }, + { value: "TRACE", label: "Trace" }, + { value: "PING", label: "Ping" }, +]; + interface TraceListProps { onAnalyze: (hash: string | null) => void; onViewNode?: (nodeId: string) => void; } +// The list now carries the most complete observation's path, so we can show the hops (and the SNR we +// heard on each) right on the card instead of making people open the detail panel for a quick look. +function TracePathPreview({ hashes, snrs }: { hashes: string[]; snrs: number[] }) { + return ( +
+ {hashes.map((hash, i) => { + const snr = snrs?.[i]; + const level = snr != null ? snrLevel(snr) : null; + const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + return ( + + {i > 0 && →} + + + {hash.toUpperCase()} + + {/* keep a sub-line on every hop (SNR or a placeholder) so the badges across the row line up */} + {snr != null ? ( + {formatSnr(snr)} dB + ) : ( + - + )} + + + ); + })} +
+ ); +} + // A trace tag as a selectable card, echoing PacketRow's look so the tab reads like the Packets tab. function TraceTagCard({ tag, selected, onSelect }: { tag: TraceTagSummary; @@ -40,11 +80,14 @@ function TraceTagCard({ tag, selected, onSelect }: { >
{tag.traceTag.toUpperCase()} + {/* pings get the primary tint, traces the amber one, so the two read apart at a glance */} + {tag.traceType && {tag.traceType}}
{tag.packetCount} pkt · {tag.iataCount} iata
+ {tag.pathHashes?.length ? : null}
); } @@ -52,6 +95,7 @@ function TraceTagCard({ tag, selected, onSelect }: { export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { const { iatas, regionKey } = useRegion(); const [selectedTag, setSelectedTag] = useState(null); + const [typeFilter, setTypeFilter] = useState<"" | TraceType>(""); // drop the selection when the region changes — the selected tag may not be in the new region const prevRegion = useRef(regionKey); @@ -63,23 +107,36 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { }, [regionKey]); const { data: tags, isLoading } = useQuery({ - queryKey: ["traces", regionKey], - queryFn: () => getTraces(iatas, { limit: TRACE_LIST_LIMIT }), + queryKey: ["traces", regionKey, typeFilter], + queryFn: () => getTraces(iatas, { limit: TRACE_LIST_LIMIT, type: typeFilter || undefined }), staleTime: 30_000, }); return (
-
- {isLoading ? ( - - ) : (tags?.length ?? 0) === 0 ? ( - - ) : ( - tags!.map((t) => ( - - )) - )} +
+
+ + {tags ? `${tags.length} tag${tags.length === 1 ? "" : "s"}` : ""} + + setTypeFilter(v as "" | TraceType)} + ariaLabel="Trace type" + /> +
+
+ {isLoading ? ( + + ) : (tags?.length ?? 0) === 0 ? ( + + ) : ( + tags!.map((t) => ( + + )) + )} +
{selectedTag && ( setSelectedTag(null)} onAnalyze={onAnalyze} onViewNode={onViewNode} /> diff --git a/src/types/api.ts b/src/types/api.ts index eeb59649..f4b68be5 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -173,12 +173,18 @@ export interface CrossIATARoute { // trace tags — a trace series groups the packets that share a 4-byte trace tag. The list endpoint // returns per-tag summaries; the detail endpoint returns the tag's packets with their resolved routes. +// Each tag is either a route TRACE or a PING (round-trip) — the backend tags which. +export type TraceType = "TRACE" | "PING"; + export interface TraceTagSummary { traceTag: string; // hex-encoded 4-byte tag firstHeardAt: number; // epoch ms lastHeardAt: number; // epoch ms packetCount: number; iataCount: number; // distinct IATAs the tag was heard in + traceType: TraceType; + pathHashes: string[]; // hops from the most complete observation we've seen for this tag + snrValues: number[]; // per-hop SNR (dB), index-aligned with pathHashes } export interface RawHop { diff --git a/tests/features/traces/TraceList.test.tsx b/tests/features/traces/TraceList.test.tsx index 8d1bc8bf..d070e3aa 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -19,8 +19,8 @@ const mockGetTraces = vi.mocked(getTraces); const mockGetTraceDetail = vi.mocked(getTraceDetail); const mockGetRegions = vi.mocked(getRegions); -function tag(traceTag: string, packetCount = 1): TraceTagSummary { - return { traceTag, firstHeardAt: 1, lastHeardAt: 2, packetCount, iataCount: 1 }; +function tag(traceTag: string, packetCount = 1, extra: Partial = {}): TraceTagSummary { + return { traceTag, firstHeardAt: 1, lastHeardAt: 2, packetCount, iataCount: 1, traceType: "TRACE", pathHashes: [], snrValues: [], ...extra }; } const detail: TraceDetail = { @@ -147,6 +147,33 @@ describe("TraceList", () => { expect(await screen.findByRole("tooltip")).toHaveTextContent("GatewayX"); }); + it("tags each card as TRACE or PING and previews the most complete path with per-hop SNR", async () => { + mockGetTraces.mockResolvedValue([ + tag("3f2a11c0", 4, { traceType: "PING", pathHashes: ["a1", "b2"], snrValues: [-7.5, -9] }), + ]); + + renderTraces(); + + expect(await screen.findByText("3F2A11C0")).toBeInTheDocument(); + expect(screen.getByText("PING")).toBeInTheDocument(); + // the path preview shows each hop's hash byte (uppercased) with its SNR on the sub-line + expect(screen.getByText("A1")).toBeInTheDocument(); + expect(screen.getByText("B2")).toBeInTheDocument(); + expect(screen.getByText("-7.50 dB")).toBeInTheDocument(); + }); + + it("refetches with the type param when the trace-type filter changes", async () => { + mockGetTraces.mockResolvedValue([tag("3f2a11c0", 1)]); + + renderTraces(); + await screen.findByText("3F2A11C0"); + + fireEvent.click(screen.getByRole("button", { name: "Ping" })); + + // the region arg is undefined for "all regions", so assert on the params object directly + await waitFor(() => expect(mockGetTraces.mock.calls.at(-1)?.[1]).toMatchObject({ type: "PING" })); + }); + it("shows an empty state when there are no traces", async () => { mockGetTraces.mockResolvedValue([]); From a618a28014192c60ebfd8e8aa240fd15a620b961 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 20 Jun 2026 13:18:27 -0400 Subject: [PATCH 003/163] fix: github workflow, post images publically --- .github/workflows/docker-publish.yml | 4 ++++ README.md | 15 ++++++--------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 22446c18..1ee448d0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -33,6 +33,10 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.title=beacon-web + org.opencontainers.image.description=Beacon Web — real-time LoRa mesh packet analyzer + org.opencontainers.image.licenses=AGPL-3.0-or-later tags: | type=raw,value=latest,enable={{is_default_branch}} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} diff --git a/README.md b/README.md index 2c3718fd..64e39577 100644 --- a/README.md +++ b/README.md @@ -33,20 +33,17 @@ EOF | `VITE_API_BASE` | Backend REST API base URL | | `VITE_WS_URL` | Backend WebSocket URL | -### 3. Authenticate with GitHub Container Registry - -```bash -docker login ghcr.io -u YOUR_GITHUB_USERNAME -``` - -Use a Personal Access Token (classic) with `read:packages` scope as the password. You only need to do this once. - -### 4. Start the services +### 3. Start the services ```bash docker compose up -d ``` +The images are public on GitHub Container Registry — no `docker login` required. +If a pull fails with `403 Forbidden`, the package visibility has regressed to +Private; a maintainer needs to set it back to Public (see the troubleshooting note +in [beacon-docs](https://github.com/MeshCore-Beacon/beacon-docs)). + Caddy will automatically obtain a TLS certificate for your domain. Ensure DNS is pointed at your server before starting. ## Local Development From 55fda1be8a65af87ee5926ae04774ce9e4e96078 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Jun 2026 13:33:43 -0400 Subject: [PATCH 004/163] build(deps): bump undici from 7.27.2 to 7.28.0 (#9) Bumps [undici](https://github.com/nodejs/undici) from 7.27.2 to 7.28.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.27.2...v7.28.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.28.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 105 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 81 insertions(+), 24 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3baa1251..cc53d78d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "0.0.0", + "version": "1.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "0.0.0", + "version": "1.0.0", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", @@ -128,7 +128,6 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -449,7 +448,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" }, @@ -498,7 +496,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=20.19.0" } @@ -1464,6 +1461,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.10.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", @@ -1666,7 +1729,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/chai": { "version": "5.2.3", @@ -1719,7 +1783,6 @@ "integrity": "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.18.0" } @@ -1730,7 +1793,6 @@ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1741,7 +1803,6 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", - "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -1791,7 +1852,6 @@ "integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.61.0", "@typescript-eslint/types": "8.61.0", @@ -2135,7 +2195,6 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2176,6 +2235,7 @@ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -2186,6 +2246,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -2279,7 +2340,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -2459,7 +2519,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/earcut": { "version": "3.0.2", @@ -2547,7 +2608,6 @@ "integrity": "sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", @@ -3439,6 +3499,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -3707,7 +3768,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -3766,6 +3826,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -3802,7 +3863,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -3812,7 +3872,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -3825,7 +3884,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-router": { "version": "7.17.0", @@ -4196,7 +4256,6 @@ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4230,9 +4289,9 @@ } }, "node_modules/undici": { - "version": "7.27.2", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.27.2.tgz", - "integrity": "sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "dev": true, "license": "MIT", "engines": { @@ -4293,7 +4352,6 @@ "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", @@ -4590,7 +4648,6 @@ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From e50501879a8ce6514168600a0f7f84d521f51ed0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 5 Jul 2026 22:08:54 -0400 Subject: [PATCH 005/163] feat: discover renderers, neighbor map lines, live packet flow - Discover packets decoded: DISCOVER_REQ/DISCOVER_RESP render real fields (type-filter roles, tag, node-to-node request SNR) instead of a raw dump. - Neighbor lines on the map: On/Selected/Off control draws links between nodes and their known neighbors. - Live packet flow: a play button animates packets hop-to-hop between repeaters in real time (off by default). --- src/api/client.ts | 2 + src/api/ws-manager.ts | 22 +++ src/features/map/MapSettingsPanel.tsx | 20 ++- src/features/map/MapView.tsx | 34 +++- src/features/map/PacketFlowButton.tsx | 35 ++++ src/features/map/node-geojson.ts | 42 ++++- src/features/map/packet-flow.ts | 84 +++++++++ src/features/map/types.ts | 13 ++ src/features/map/useMapNeighbors.ts | 81 +++++++++ src/features/map/useMapNodesData.ts | 4 +- src/features/map/useMapPacketFlow.ts | 159 ++++++++++++++++++ src/features/nodes/types.ts | 1 + src/features/packets/packet-structure.tsx | 14 +- src/features/packets/payload-renderers.tsx | 81 +++++---- src/types/ws.ts | 12 ++ tests/api/ws-manager.test.ts | 47 ++++++ tests/features/map/node-geojson.test.ts | 42 ++++- tests/features/map/packet-flow.test.ts | 55 ++++++ tests/features/map/useMapNodesData.test.tsx | 4 +- .../packets/payload-renderers.test.tsx | 52 ++++++ 20 files changed, 754 insertions(+), 50 deletions(-) create mode 100644 src/features/map/PacketFlowButton.tsx create mode 100644 src/features/map/packet-flow.ts create mode 100644 src/features/map/useMapNeighbors.ts create mode 100644 src/features/map/useMapPacketFlow.ts create mode 100644 tests/features/map/packet-flow.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index c085d8a0..52a28d22 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -208,6 +208,7 @@ export function getNodesPage( name?: string; supportsMultibytePaths?: "true" | "false"; supportsMultibyteTraces?: "true" | "false"; + neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, ): Promise> { return request("/nodes", { @@ -218,6 +219,7 @@ export function getNodesPage( name: params?.name, supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, + neighbors: params?.neighbors ? "true" : undefined, }); } diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index 93f71a4d..ffda8b4e 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -21,6 +21,9 @@ export class WsManager { private ws: WebSocket | null = null; private url: string; private filter: SubscriptionFilter | null = null; + // connection-wide toggle for resolvedPath on packetObservation events; survives reconnect (like + // filter), re-applied on each hello. Default off keeps the event payload small. + private resolvePath = false; private subscriptionId: string | null = null; private lastSubscribeId: string | null = null; private everConnected = false; @@ -113,6 +116,16 @@ export class WsManager { this.sendSubscribe(); } + // Enable/disable per-hop resolvedPath data on packetObservation events for the whole connection. + // Stored so it re-applies after a reconnect; sent immediately when the socket is already open. + setResolvePath(enabled: boolean): void { + if (this.resolvePath === enabled) return; + this.resolvePath = enabled; + if (this.ws?.readyState === WebSocket.OPEN) { + this.sendConfigure(); + } + } + disconnect(): void { this.intentionalClose = true; this.reconnectAttempt = 0; @@ -174,6 +187,7 @@ export class WsManager { this.setStatus("connected"); this.startPing(); this.sendSubscribe(); + if (this.resolvePath) this.sendConfigure(); // re-apply the connection-wide toggle if (isReconnect) { // we were dark during the outage — synthesize a lag notice so live views heal the gap const notice: WsLagged = { v: 1, type: "lagged", droppedCount: 0, since: this.lastEventTimestamp }; @@ -193,6 +207,10 @@ export class WsManager { } break; + case "configured": + // ack for our resolvePath toggle; nothing to do beyond the server now honoring it + break; + case "pong": // a pong proves the link is alive, so it counts as recent activity this.lastEventTimestamp = Date.now(); @@ -244,6 +262,10 @@ export class WsManager { }); } + private sendConfigure(): void { + this.send({ v: 1, type: "configure", id: `cfg-${this.nextId()}`, resolvePath: this.resolvePath }); + } + private startPing(): void { if (this.pingTimer) clearInterval(this.pingTimer); // a second hello must not double the interval this.pingTimer = setInterval(() => { diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 5b88cf09..32834597 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { MapStyleSwitcher } from "./MapStyleSwitcher"; import { SegmentedControl } from "./SegmentedControl"; -import { NODE_TYPE_FILTER_OPTIONS } from "./types"; +import { NODE_TYPE_FILTER_OPTIONS, type NeighborLinesMode } from "./types"; import { Section } from "../../components/DetailPanel"; import { useIsMobile } from "../../hooks/useMediaQuery"; @@ -13,6 +13,11 @@ const CLUSTER_OPTIONS = [ { value: "on", label: "On" }, { value: "off", label: "Off" }, ]; +const NEIGHBOR_OPTIONS = [ + { value: "on", label: "On" }, + { value: "selected", label: "Selected" }, + { value: "off", label: "Off" }, +]; interface MapSettingsPanelProps { styleId: string; @@ -21,6 +26,8 @@ interface MapSettingsPanelProps { onTypeChange: (t: string) => void; clustered: boolean; onClusteredChange: (c: boolean) => void; + neighborLines: NeighborLinesMode; + onNeighborLinesChange: (mode: NeighborLinesMode) => void; } export function MapSettingsPanel({ @@ -30,6 +37,8 @@ export function MapSettingsPanel({ onTypeChange, clustered, onClusteredChange, + neighborLines, + onNeighborLinesChange, }: MapSettingsPanelProps) { const isMobile = useIsMobile(); // collapsed by default on mobile (the card would cover the map); a saved preference still wins @@ -91,6 +100,15 @@ export function MapSettingsPanel({ className="w-full" />
+
+ onNeighborLinesChange(v as NeighborLinesMode)} + className="w-full" + /> +
)} diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 6e7d7e39..d24a131d 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -3,10 +3,14 @@ import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-que import "maplibre-gl/dist/maplibre-gl.css"; import { useMapLibre } from "./useMapLibre"; import { useMapNodes } from "./useMapNodes"; +import { useMapNeighbors } from "./useMapNeighbors"; +import { useMapPacketFlow } from "./useMapPacketFlow"; +import { PacketFlowButton } from "./PacketFlowButton"; import { useMapNodesData } from "./useMapNodesData"; -import { nodesToFeatureCollection, filterByNodeType } from "./node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, type NeighborEdgeProps } from "./node-geojson"; import { MapSettingsPanel } from "./MapSettingsPanel"; -import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle } from "./types"; +import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, type NeighborLinesMode } from "./types"; +import type { FeatureCollection, LineString } from "geojson"; import { EmptyState } from "../../components/EmptyState"; import { LoadingPill } from "../../components/LoadingPill"; import { useRegion } from "../../hooks/useRegion"; @@ -19,6 +23,8 @@ import type { NodeSummary } from "../nodes/types"; import type { CursorPage } from "../../types/api"; import type { WsNodeUpdate } from "../../types/ws"; +const EMPTY_EDGES: FeatureCollection = { type: "FeatureCollection", features: [] }; + interface MapViewProps { wsManager: WsManager; // shared with the Nodes tab (lifted to AppInner) so the open NodeDetailPanel stays live @@ -47,6 +53,18 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const [typeFilter, setTypeFilter] = useState(""); // "" = All const [clustered, setClustered] = useState(true); + const [neighborLines, setNeighborLines] = useState(() => { + const stored = localStorage.getItem(MAP_NEIGHBOR_LINES_STORAGE_KEY); + return stored === "on" || stored === "selected" ? stored : "off"; + }); + const handleNeighborLinesChange = useCallback((mode: NeighborLinesMode) => { + setNeighborLines(mode); + localStorage.setItem(MAP_NEIGHBOR_LINES_STORAGE_KEY, mode); + }, []); + + // live packet-flow animation: opt-in per session (off by default, not persisted) + const [packetFlow, setPacketFlow] = useState(false); + const { iatas: selectedIatas, regionKey } = useRegion(); const queryClient = useQueryClient(); // marker/cluster icons are canvas-drawn from the active --palette-* vars, so useMapNodes has to @@ -83,6 +101,13 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const baseFc = useMemo(() => nodesToFeatureCollection(nodes), [nodes]); const geojson = useMemo(() => filterByNodeType(baseFc, typeFilter), [baseFc, typeFilter]); + // Neighbor edges are a pure client-side render over already-loaded nodes (neighborIds ship with + // every map page), so toggling On/Selected/Off never refetches. "off" short-circuits to no edges. + const neighborEdges = useMemo( + () => (neighborLines === "off" ? EMPTY_EDGES : buildNeighborEdges(nodes, neighborLines, selectedNodeId)), + [nodes, neighborLines, selectedNodeId], + ); + // IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no // bounds from the API, so their member IATAs stand in for the extent. See CLAUDE.md (map framing). const fitPoints = useMemo<[number, number][] | null>(() => { @@ -97,6 +122,8 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, `${regionKey}:${typeFilter}`); + useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); + useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); return (
@@ -111,7 +138,10 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp onTypeChange={setTypeFilter} clustered={clustered} onClusteredChange={setClustered} + neighborLines={neighborLines} + onNeighborLinesChange={handleNeighborLinesChange} /> + setPacketFlow((v) => !v)} /> {/* streams in 50 at a time; the count climbs as pages land, then the pill disappears */} {error && ( diff --git a/src/features/map/PacketFlowButton.tsx b/src/features/map/PacketFlowButton.tsx new file mode 100644 index 00000000..61862cd1 --- /dev/null +++ b/src/features/map/PacketFlowButton.tsx @@ -0,0 +1,35 @@ +interface PacketFlowButtonProps { + active: boolean; + onToggle: () => void; +} + +// Floating play/stop control for the live packet-flow animation. Off by default; when live it shows +// a pulsing dot and accent styling. Bottom-center, clear of the corner map controls. +export function PacketFlowButton({ active, onToggle }: PacketFlowButtonProps) { + return ( + + ); +} diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index eb6fef30..a0a69a78 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -1,4 +1,4 @@ -import type { Feature, FeatureCollection, Point } from "geojson"; +import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { NodeSummary } from "../nodes/types"; // Build the maplibre GeoJSON source from the nodes API response. Properties stay primitive because @@ -33,6 +33,46 @@ export function nodesToFeatureCollection( return { type: "FeatureCollection", features }; } +export interface NeighborEdgeProps { + selected: boolean; // incident to the currently selected node — styled brighter +} + +// LineString edges between located nodes and their neighbors (from each node's neighborIds). Each +// undirected pair is emitted once, and only when both ends are located nodes in this set. "selected" +// keeps just the selected node's edges; "on" emits all and flags its edges with the `selected` prop. +export function buildNeighborEdges( + nodes: NodeSummary[], + mode: "on" | "selected", + selectedId: string | null, +): FeatureCollection { + const located = new Map(); + for (const n of nodes) { + if (n.lat != null && n.lng != null) located.set(n.id, n); + } + + const seen = new Set(); + const features: Feature[] = []; + for (const n of nodes) { + if (n.lat == null || n.lng == null || !n.neighborIds) continue; + for (const otherId of n.neighborIds) { + if (otherId === n.id) continue; // a node listing itself would draw a zero-length edge + const other = located.get(otherId); + if (!other) continue; + const key = n.id < otherId ? `${n.id}|${otherId}` : `${otherId}|${n.id}`; + if (seen.has(key)) continue; + const incident = n.id === selectedId || otherId === selectedId; + if (mode === "selected" && !incident) continue; + seen.add(key); + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: [[n.lng, n.lat], [other.lng!, other.lat!]] }, + properties: { selected: incident }, + }); + } + } + return { type: "FeatureCollection", features }; +} + // Filter to a single device type ("" = All). Filtering the data (not a layer filter) lets the // clustered source re-count only the visible type. export function filterByNodeType( diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts new file mode 100644 index 00000000..389a0aa2 --- /dev/null +++ b/src/features/map/packet-flow.ts @@ -0,0 +1,84 @@ +import type { Feature, FeatureCollection, Point } from "geojson"; +import type { ResolvedHop } from "../../types/api"; + +// Geometry helpers for the packet-flow animation. No maplibre import, so they stay unit-testable. + +// The [lng, lat] path a packet took, one point per resolved hop. Each hop uses its first located +// candidate; hops we can't place are dropped, so the route can come back with fewer than 2 points. +export function resolvedPathToRoute(resolvedPath: ResolvedHop[]): [number, number][] { + const route: [number, number][] = []; + for (const hop of resolvedPath) { + const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null); + if (node) route.push([node.longitude!, node.latitude!]); + } + return route; +} + +// Cumulative segment lengths (planar distance in degrees — accurate enough at mesh scale) so a pulse +// can be placed by fraction of total path length rather than fraction of hop count. +export function routeMetrics(coords: [number, number][]): { cumLengths: number[]; total: number } { + const cumLengths: number[] = [0]; + for (let i = 1; i < coords.length; i++) { + const [x0, y0] = coords[i - 1]!; + const [x1, y1] = coords[i]!; + cumLengths.push(cumLengths[i - 1]! + Math.hypot(x1 - x0, y1 - y0)); + } + return { cumLengths, total: cumLengths[cumLengths.length - 1] ?? 0 }; +} + +// Interpolated [lng, lat] at fraction t in [0,1] along the route, by cumulative length. +export function positionAt( + coords: [number, number][], + cumLengths: number[], + total: number, + t: number, +): [number, number] { + if (coords.length === 1 || total === 0) return coords[0]!; + const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; + const target = clamped * total; + let i = 1; + while (i < cumLengths.length - 1 && cumLengths[i]! < target) i++; + const segStart = cumLengths[i - 1]!; + const segEnd = cumLengths[i]!; + const segFrac = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart); + const [x0, y0] = coords[i - 1]!; + const [x1, y1] = coords[i]!; + return [x0 + (x1 - x0) * segFrac, y0 + (y1 - y0) * segFrac]; +} + +// One in-flight packet animation. cumLengths/total are precomputed (routeMetrics) so each frame is +// just an interpolation. +export interface Pulse { + id: number; + coords: [number, number][]; + cumLengths: number[]; + total: number; + startMs: number; + durationMs: number; +} + +export interface PulseFeatureProps { + opacity: number; +} + +// Elapsed fraction of a pulse's life; >1 once it has arrived (the caller expires those). +export function pulseProgress(pulse: Pulse, nowMs: number): number { + return pulse.durationMs <= 0 ? 1 : (nowMs - pulse.startMs) / pulse.durationMs; +} + +// Snapshot the live pulses as point features at their current positions. Opacity holds at 1 then +// eases out over the final quarter so a pulse fades as it reaches the last repeater. +export function buildPulseFC(pulses: Pulse[], nowMs: number): FeatureCollection { + const features: Feature[] = []; + for (const p of pulses) { + const t = pulseProgress(p, nowMs); + const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; + const opacity = clamped < 0.75 ? 1 : Math.max(0, 1 - (clamped - 0.75) / 0.25); + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, clamped) }, + properties: { opacity }, + }); + } + return { type: "FeatureCollection", features }; +} diff --git a/src/features/map/types.ts b/src/features/map/types.ts index 1f7c6a69..ab6c6724 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -90,6 +90,19 @@ export const NODES_SELECTED_LAYER_ID = "nodes-selected"; // circle ring under th // NODES_SELECTED_LAYER_ID can't reach it). Fed by its own geojson source, pointed at the leaf. export const NODES_SELECTED_LEAF_LAYER_ID = "nodes-selected-leaf"; +// --- Neighbor edges layer --- +export const NEIGHBORS_SOURCE_ID = "neighbors"; +export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn beneath the node markers +export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines"; +export type NeighborLinesMode = "on" | "selected" | "off"; + +// --- Live packet-flow animation --- +export const PACKET_FLOW_SOURCE_ID = "packet-flow"; +export const PACKET_FLOW_LAYER_ID = "packet-flow-pulses"; // circle layer on top (moving pulse) +export const PACKET_FLOW_MAX_PULSES = 60; // cap concurrent animations; drop the oldest past this +export const PACKET_FLOW_SEGMENT_MS = 700; // pulse travel time per hop segment +export const PACKET_FLOW_DEDUP_MS = 3000; // collapse repeat observations of one packetHash within this window + export const CLUSTER_RADIUS = 50; // px // Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops // clustering above clusterMaxZoom, which would leave co-located nodes as stacked, un-spiderfy-able diff --git a/src/features/map/useMapNeighbors.ts b/src/features/map/useMapNeighbors.ts new file mode 100644 index 00000000..0d449639 --- /dev/null +++ b/src/features/map/useMapNeighbors.ts @@ -0,0 +1,81 @@ +import { useEffect, useRef } from "react"; +import type { Map as MapLibreMap, GeoJSONSource, LineLayerSpecification } from "maplibre-gl"; +import type { FeatureCollection, LineString } from "geojson"; +import type { NeighborEdgeProps } from "./node-geojson"; +import { NEIGHBORS_SOURCE_ID, NEIGHBORS_LINE_LAYER_ID, NODES_CLUSTER_LAYER_ID } from "./types"; + +type EdgeFC = FeatureCollection; + +function paletteVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +// Draws neighbor edges as a line layer beneath the node markers. Like useMapNodes, the source and +// layer re-add themselves after a style switch, and edge data flows through a separate setData +// effect so toggling or changing the selection never rebuilds the layer. +export function useMapNeighbors( + mapRef: React.RefObject, + isReady: boolean, + edges: EdgeFC, + themeKey: string, +) { + const edgesRef = useRef(edges); + useEffect(() => { + edgesRef.current = edges; + }, [edges]); + + // build source + layer, and keep the line color in step with the palette + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + const primary = paletteVar("--palette-primary", "#3B82F6"); + + if (!map.getSource(NEIGHBORS_SOURCE_ID)) { + map.addSource(NEIGHBORS_SOURCE_ID, { type: "geojson", data: edgesRef.current }); + } + if (!map.getLayer(NEIGHBORS_LINE_LAYER_ID)) { + map.addLayer( + { + id: NEIGHBORS_LINE_LAYER_ID, + type: "line", + source: NEIGHBORS_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { + "line-color": primary, + // edges touching the selected node read stronger than the ambient mesh + "line-width": ["case", ["get", "selected"], 2, 1], + "line-opacity": ["case", ["get", "selected"], 0.9, 0.3], + }, + } as LineLayerSpecification, + // beneath the node markers; guard the beforeId in case the nodes layer isn't added yet + map.getLayer(NODES_CLUSTER_LAYER_ID) ? NODES_CLUSTER_LAYER_ID : undefined, + ); + } + map.setPaintProperty(NEIGHBORS_LINE_LAYER_ID, "line-color", primary); + (map.getSource(NEIGHBORS_SOURCE_ID) as GeoJSONSource).setData(edgesRef.current); + }, [mapRef, isReady, themeKey]); + + // push new edge data as the selection / toggle / node set changes + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const src = map.getSource(NEIGHBORS_SOURCE_ID) as GeoJSONSource | undefined; + if (src) src.setData(edges); + }, [mapRef, isReady, edges]); + + // remove the layer + source on unmount. Capturing map here is safe: it's the same instance for the + // component's life, and this cleanup runs before useMapLibre tears the map down. + useEffect(() => { + const map = mapRef.current; + return () => { + if (!map) return; + try { + if (map.getLayer(NEIGHBORS_LINE_LAYER_ID)) map.removeLayer(NEIGHBORS_LINE_LAYER_ID); + if (map.getSource(NEIGHBORS_SOURCE_ID)) map.removeSource(NEIGHBORS_SOURCE_ID); + } catch { + // map may already be torn down + } + }; + }, [mapRef]); +} diff --git a/src/features/map/useMapNodesData.ts b/src/features/map/useMapNodesData.ts index aa1b44e7..28faecb0 100644 --- a/src/features/map/useMapNodesData.ts +++ b/src/features/map/useMapNodesData.ts @@ -10,7 +10,9 @@ const nodeId = (n: NodeSummary) => n.id; export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string) { const { items, loadedCount, isPaging, isError } = useInfinitePages({ queryKey: ["map-nodes", regionKey], - queryFn: (cursor) => getNodesPage(selectedIatas, { cursor }), + // Always request neighborIds (just UUIDs) so the neighbor-lines toggle is a pure client-side + // render switch over already-loaded data — no refetch when toggling. + queryFn: (cursor) => getNodesPage(selectedIatas, { cursor, neighbors: true }), getId: nodeId, }); return { nodes: items, loadedCount, isPaging, isError }; diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts new file mode 100644 index 00000000..8d51b4d9 --- /dev/null +++ b/src/features/map/useMapPacketFlow.ts @@ -0,0 +1,159 @@ +import { useCallback, useEffect, useRef } from "react"; +import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification } from "maplibre-gl"; +import type { FeatureCollection } from "geojson"; +import type { WsManager } from "../../api/ws-manager"; +import { resolvedPathToRoute, routeMetrics, buildPulseFC, pulseProgress, type Pulse } from "./packet-flow"; +import { + PACKET_FLOW_SOURCE_ID, + PACKET_FLOW_LAYER_ID, + PACKET_FLOW_MAX_PULSES, + PACKET_FLOW_SEGMENT_MS, + PACKET_FLOW_DEDUP_MS, +} from "./types"; + +const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; + +function paletteVar(name: string, fallback: string): string { + return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +} + +// Live "packets moving between repeaters" overlay. While enabled it turns on resolvedPath over the +// WS (setResolvePath) and animates a pulse along each observed packet's path. Geometry is pure +// (packet-flow.ts); here we own the maplibre source, the rAF loop, and the subscription. +export function useMapPacketFlow( + mapRef: React.RefObject, + isReady: boolean, + enabled: boolean, + wsManager: WsManager, + themeKey: string, + resetKey: string, +) { + const pulsesRef = useRef([]); + const rafRef = useRef(null); + const nextIdRef = useRef(0); + const recentRef = useRef>(new Map()); + + // start the rAF loop if it's idle. The frame reschedules itself until the last pulse expires, then + // leaves rafRef null so we stop instead of spinning on an empty source. + const startLoop = useCallback(() => { + if (rafRef.current != null) return; + function frame() { + const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; + const now = performance.now(); + pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1); + if (src) src.setData(buildPulseFC(pulsesRef.current, now)); + rafRef.current = pulsesRef.current.length > 0 ? requestAnimationFrame(frame) : null; + } + rafRef.current = requestAnimationFrame(frame); + }, [mapRef]); + + // build the pulse source + layer; re-adds itself after every style switch, re-tints on theme change + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + const accent = paletteVar("--palette-primary", "#3B82F6"); + if (!map.getSource(PACKET_FLOW_SOURCE_ID)) { + map.addSource(PACKET_FLOW_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + if (!map.getLayer(PACKET_FLOW_LAYER_ID)) { + // no beforeId: draw on top of the node markers so the moving pulse stays visible + map.addLayer({ + id: PACKET_FLOW_LAYER_ID, + type: "circle", + source: PACKET_FLOW_SOURCE_ID, + paint: { + "circle-radius": 5, + "circle-color": accent, + "circle-opacity": ["get", "opacity"], + "circle-stroke-width": 1.5, + "circle-stroke-color": accent, + "circle-stroke-opacity": ["*", ["get", "opacity"], 0.5], + }, + } as CircleLayerSpecification); + } + map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-color", accent); + map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent); + }, [mapRef, isReady, themeKey]); + + // connection-wide resolvePath toggle: on when enabled, off on disable/unmount + useEffect(() => { + wsManager.setResolvePath(enabled); + return () => wsManager.setResolvePath(false); + }, [enabled, wsManager]); + + // feed observed resolved paths into new pulses; tear the animation down when disabled + useEffect(() => { + if (!enabled) return; + const map = mapRef.current; // stable for the component's life; used to clear the source on cleanup + const unsub = wsManager.onPacketObservation((data) => { + const resolved = data.observation?.resolvedPath; + if (!resolved || resolved.length === 0) return; + + const now = performance.now(); + // many observers report the same packet — animate it once per dedup window + const seenAt = recentRef.current.get(data.packetHash); + if (seenAt != null && now - seenAt < PACKET_FLOW_DEDUP_MS) return; + + const coords = resolvedPathToRoute(resolved); + if (coords.length < 2) return; // nothing to draw between + const { cumLengths, total } = routeMetrics(coords); + if (total === 0) return; + + // record only after we know this observation produced a pulse, so a partially-resolved report + // doesn't suppress a later fully-resolved one for the same packet + recentRef.current.set(data.packetHash, now); + for (const [hash, ts] of recentRef.current) { + if (now - ts > PACKET_FLOW_DEDUP_MS) recentRef.current.delete(hash); + } + + pulsesRef.current.push({ + id: nextIdRef.current++, + coords, + cumLengths, + total, + startMs: now, + durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS, + }); + if (pulsesRef.current.length > PACKET_FLOW_MAX_PULSES) { + pulsesRef.current.splice(0, pulsesRef.current.length - PACKET_FLOW_MAX_PULSES); + } + startLoop(); + }); + + return () => { + unsub(); + pulsesRef.current = []; + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + const src = map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; + src?.setData(EMPTY_FC); + }; + }, [enabled, wsManager, mapRef, startLoop]); + + // clear in-flight pulses when the region changes (their geometry came from the old dataset) + useEffect(() => { + pulsesRef.current = []; + recentRef.current.clear(); + const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; + src?.setData(EMPTY_FC); + }, [resetKey, mapRef]); + + // remove the layer + source on unmount (runs before useMapLibre's map.remove()) + useEffect(() => { + const map = mapRef.current; + return () => { + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + rafRef.current = null; + if (!map) return; + try { + if (map.getLayer(PACKET_FLOW_LAYER_ID)) map.removeLayer(PACKET_FLOW_LAYER_ID); + if (map.getSource(PACKET_FLOW_SOURCE_ID)) map.removeSource(PACKET_FLOW_SOURCE_ID); + } catch { + // map may already be torn down + } + }; + }, [mapRef]); +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 2c24ff2f..a748f445 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -15,6 +15,7 @@ export interface NodeSummary { defaultScope?: string; // most recently matched transport scope name, e.g. "#bc" iatas: NodeIATA[]; knownNeighborCount: number; // distinct first-hop neighbors we've resolved for this node + neighborIds?: string[]; // first-hop neighbor node ids; only present when the list request opts in (?neighbors) // Set when this node also runs as an observer (watches traffic for uplink). isObserver drives the // map's observer-pip marker variant; observerId, when present, links to that observer's detail. isObserver?: boolean; diff --git a/src/features/packets/packet-structure.tsx b/src/features/packets/packet-structure.tsx index 074b797f..b53b86ea 100644 --- a/src/features/packets/packet-structure.tsx +++ b/src/features/packets/packet-structure.tsx @@ -6,6 +6,14 @@ import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from ".. import { Timestamp } from "../../components/Timestamp"; import { IataChip } from "../../components/IataChip"; +// Advert device-role (ADV_TYPE) low-nibble names, shared with the DISCOVER payload renderers. +export const DEVICE_ROLE_NAMES: Record = { + 0x01: "ChatNode", + 0x02: "Repeater", + 0x03: "RoomServer", + 0x04: "Sensor", +}; + // maps packet bytes to named field ranges for hex coloring export type FieldId = "header" | "transport" | "pathLength" | "pathData" | "payload" | "channelHash" | "cipherMac" | "ciphertext" | "publicKey" | "signature" | "advertTimestamp" | "flags" | "location" | "advertName" | "destinationHash" | "sourceHash" | "senderPublicKey" | "checksum" | "traceTag" | "authCode" | "tracePath"; @@ -337,12 +345,6 @@ export function AdvertFlagsBitBreakdown({ flagsByte }: { flagsByte: number }) { const nm = bits[0]; const role = flagsByte & 0x0f; - const DEVICE_ROLE_NAMES: Record = { - 0x01: "ChatNode", - 0x02: "Repeater", - 0x03: "RoomServer", - 0x04: "Sensor", - }; const roleName = DEVICE_ROLE_NAMES[role] ?? `0x${role.toString(16)}`; return ( diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index c9982540..e36e3cc3 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -2,7 +2,7 @@ import type { ReactNode } from "react"; import { Badge } from "../../components/Badge"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; -import { ColorAccentField, AdvertFlagsBitBreakdown, PathLengthBitBreakdown, FIELD_COLORS } from "./packet-structure"; +import { ColorAccentField, AdvertFlagsBitBreakdown, PathLengthBitBreakdown, FIELD_COLORS, DEVICE_ROLE_NAMES } from "./packet-structure"; import type { FieldId } from "./packet-structure"; import { ResolvedHopBlock } from "./PathData"; import type { ResolvedHop } from "../../types/api"; @@ -449,27 +449,36 @@ function PathDecryptedContent({ decrypted }: { decrypted: Record i).filter((i) => (typeFilter >> i) & 1) + : []; + return (
- {tag != null && ( + {tag && ( - 0x{tag.toString(16).toUpperCase()} + 0x{tag.toUpperCase()} )} - {typeFilterNames && typeFilterNames.length > 0 && ( + {typeFilter != null && (
Type Filter - - {typeFilterNames.map((name) => ( - - ))} - + {roles.length > 0 ? ( + + {roles.map((i) => ( + + ))} + + ) : ( + any + )}
)} {since != null && ( @@ -484,21 +493,21 @@ function DiscoverReqFields({ payload }: PayloadProps) { ); } -function DiscoverRespFields({ payload }: PayloadProps) { - const tag = payload.tag as number | undefined; +function DiscoverRespPayload({ payload }: PayloadProps) { + const tag = payload.tag as string | undefined; const nodeTypeName = payload.nodeTypeName as string | undefined; - const snr = payload.snr as number | undefined; - const publicKey = payload.publicKey as string | undefined; - const publicKeyLength = payload.publicKeyLength as number | undefined; + const requestSnr = payload.requestSnr as number | undefined; + const pubKey = payload.pubKey as string | undefined; + const pubKeyPrefixOnly = payload.pubKeyPrefixOnly as boolean | undefined; - const level = snr != null ? snrLevel(snr) : null; + const level = requestSnr != null ? snrLevel(requestSnr) : null; const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; return (
- {tag != null && ( + {tag && ( - 0x{tag.toString(16).toUpperCase()} + 0x{tag.toUpperCase()} )} {nodeTypeName && ( @@ -507,29 +516,27 @@ function DiscoverRespFields({ payload }: PayloadProps) { {nodeTypeName}
)} - {snr != null && ( - {formatSnr(snr)} dB + {requestSnr != null && ( + + {formatSnr(requestSnr)} dB + (node-to-node) + )} - {publicKey && ( - + {pubKey && ( + )}
); } +// Non-DISCOVER control payloads have no dedicated fields yet, so fall back to the generic dump. function ControlPayload({ payload }: PayloadProps) { - const subTypeName = payload.subTypeName as string | undefined; - - return ( -
- {subTypeName &&
{subTypeName}
} - {subTypeName === "DISCOVER_REQ" && } - {subTypeName === "DISCOVER_RESP" && } - {subTypeName !== "DISCOVER_REQ" && subTypeName !== "DISCOVER_RESP" && ( - - )} -
- ); + return ; } function RawPayload({ payload }: PayloadProps) { @@ -681,6 +688,8 @@ export function PayloadBreakdown({ payload, resolvedRoute, onViewNode }: { case "ACK": return ; case "PATH": return ; case "CONTROL": return ; + case "DISCOVER_REQ": return ; + case "DISCOVER_RESP": return ; case "GROUP_DATA": return ; case "RAW": return ; // MULTIPART carries structured remaining/wrappedType/wrappedPayload fields, which the diff --git a/src/types/ws.ts b/src/types/ws.ts index 3a0590af..bbff970f 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,5 +1,6 @@ import type { ChannelMessage } from "../features/channels/types"; import type { NodeIATA } from "../features/nodes/types"; +import type { ResolvedHop } from "./api"; // individual server-sent message shapes @@ -29,6 +30,13 @@ export interface WsPong { id: string; } +export interface WsConfigured { + v: 1; + type: "configured"; + id: string; + resolvePath: boolean; +} + export interface WsPacketObservation { v: 1; type: "event"; @@ -52,6 +60,9 @@ export interface WsPacketObservation { rssi: number; snr: number; sourceBroker: string; + // per-hop resolved path; populated only when the connection opts in via configure{resolvePath}, + // null otherwise. Same shape as the REST Observation.resolvedPath. + resolvedPath?: ResolvedHop[] | null; }; }; } @@ -124,6 +135,7 @@ export type WsServerMessage = | WsSubscribed | WsUnsubscribed | WsPong + | WsConfigured | WsPacketObservation | WsObserverStatus | WsNodeUpdate diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index a230a506..913b62c8 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -136,6 +136,53 @@ describe("WsManager", () => { expect(sub.scope.iatas).toEqual(["YOW"]); }); + it("sends a configure frame when resolvePath is enabled while connected", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + + mgr.setResolvePath(true); + + const configure = JSON.parse(ws.sent.at(-1)!); + expect(configure.type).toBe("configure"); + expect(configure.resolvePath).toBe(true); + }); + + it("re-sends the resolvePath configure after a reconnect", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + mgr.setResolvePath(true); + ws1.simulateClose(1006); + + vi.advanceTimersByTime(1500); + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateMessage({ v: 1, type: "hello", serverTime: 456, connectionId: "def" }); + + const frames = ws2.sent.map((s) => JSON.parse(s)); + expect(frames.some((f) => f.type === "configure" && f.resolvePath === true)).toBe(true); + }); + + it("handles a configured reply without throwing", () => { + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + + const ws = MockWebSocket.instances[0]!; + ws.simulateOpen(); + ws.simulateMessage({ v: 1, type: "hello", serverTime: 123, connectionId: "abc" }); + + expect(() => + ws.simulateMessage({ v: 1, type: "configured", id: "cfg-1", resolvePath: true }), + ).not.toThrow(); + }); + it("updates subscription without reconnecting", () => { const mgr = new WsManager("ws://test/ws"); mgr.connect({ iatas: ["YOW"] }); diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 0caf41ad..27eee626 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { nodesToFeatureCollection, filterByNodeType } from "../../../src/features/map/node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges } from "../../../src/features/map/node-geojson"; import type { NodeSummary } from "../../../src/features/nodes/types"; function node(overrides: Partial): NodeSummary { @@ -76,6 +76,46 @@ describe("nodesToFeatureCollection", () => { }); }); +describe("buildNeighborEdges", () => { + const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); + const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); + const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); + + it("emits one undirected edge per neighbor pair (a<->b counted once) in [lng, lat] order", () => { + const fc = buildNeighborEdges([a, b], "on", null); + expect(fc.type).toBe("FeatureCollection"); + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.geometry).toEqual({ + type: "LineString", + coordinates: [[-75, 45], [-76, 46]], + }); + }); + + it("skips neighbor ids absent from the set or without coordinates", () => { + const lonely = node({ id: "a", lat: 45, lng: -75, neighborIds: ["ghost"] }); + const noCoord = node({ id: "b", lat: null, lng: null, neighborIds: ["a"] }); + expect(buildNeighborEdges([lonely, noCoord], "on", null).features).toEqual([]); + }); + + it("in 'selected' mode keeps only edges incident to the selected node", () => { + const fc = buildNeighborEdges([a, b, c], "selected", "b"); + expect(fc.features).toHaveLength(1); // only a<->b + expect(fc.features[0]!.properties.selected).toBe(true); + }); + + it("in 'on' mode emits all edges and flags those incident to the selected node", () => { + const fc = buildNeighborEdges([a, b, c], "on", "b"); + expect(fc.features).toHaveLength(2); // a<->b and a<->c + expect(fc.features.filter((f) => f.properties.selected)).toHaveLength(1); // a<->b + }); + + it("ignores a node that lists itself as a neighbor (no zero-length edge)", () => { + const selfRef = node({ id: "a", lat: 45, lng: -75, neighborIds: ["a", "b"] }); + const fc = buildNeighborEdges([selfRef, b], "on", null); + expect(fc.features).toHaveLength(1); // a<->b only, not a<->a + }); +}); + describe("filterByNodeType", () => { const fc = nodesToFeatureCollection([ node({ id: "r1", nodeTypeName: "repeater" }), diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts new file mode 100644 index 00000000..88260411 --- /dev/null +++ b/tests/features/map/packet-flow.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { resolvedPathToRoute, routeMetrics, positionAt, buildPulseFC } from "../../../src/features/map/packet-flow"; +import type { ResolvedHop } from "../../../src/types/api"; + +// a high-confidence hop resolved to one located node at [lng, lat] +function hop(lng: number, lat: number): ResolvedHop { + return { confidence: "high", nodes: [{ id: "n", publicKey: "pk", longitude: lng, latitude: lat }] }; +} + +describe("resolvedPathToRoute", () => { + it("keeps located hops in order as [lng, lat] and drops coordless hops", () => { + const path: ResolvedHop[] = [hop(-75, 45), { confidence: "none", nodes: [] }, hop(-76, 46)]; + expect(resolvedPathToRoute(path)).toEqual([[-75, 45], [-76, 46]]); + }); + + it("returns fewer than 2 points when the path has no drawable geometry", () => { + expect(resolvedPathToRoute([{ confidence: "none", nodes: [] }])).toEqual([]); + }); +}); + +describe("routeMetrics + positionAt", () => { + it("interpolates endpoints and the midpoint of a straight segment", () => { + const coords: [number, number][] = [[0, 0], [10, 0]]; + const { cumLengths, total } = routeMetrics(coords); + expect(total).toBe(10); + expect(positionAt(coords, cumLengths, total, 0)).toEqual([0, 0]); + expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 0]); + expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([5, 0]); + }); + + it("walks the correct segment on a multi-hop route", () => { + const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; + const { cumLengths, total } = routeMetrics(coords); + expect(total).toBe(20); + expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([10, 0]); // the middle vertex + expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]); + }); +}); + +describe("buildPulseFC", () => { + const coords: [number, number][] = [[0, 0], [10, 0]]; + const { cumLengths, total } = routeMetrics(coords); + const pulse = { id: 1, coords, cumLengths, total, startMs: 1000, durationMs: 1000 }; + + it("places each pulse at its current position along the route", () => { + const fc = buildPulseFC([pulse], 1500); // halfway through + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.geometry.coordinates).toEqual([5, 0]); + }); + + it("keeps full opacity early and fades to zero at the end", () => { + expect(buildPulseFC([pulse], 1500).features[0]!.properties.opacity).toBe(1); // t=0.5 + expect(buildPulseFC([pulse], 2000).features[0]!.properties.opacity).toBe(0); // t=1 + }); +}); diff --git a/tests/features/map/useMapNodesData.test.tsx b/tests/features/map/useMapNodesData.test.tsx index 3154ef22..33f45ba4 100644 --- a/tests/features/map/useMapNodesData.test.tsx +++ b/tests/features/map/useMapNodesData.test.tsx @@ -38,8 +38,8 @@ describe("useMapNodesData", () => { expect(result.current.nodes.map((n) => n.id)).toEqual(["a", "b", "c"]); expect(result.current.loadedCount).toBe(3); expect(mockGetNodesPage).toHaveBeenCalledTimes(2); - // second call paginates with the previous page's nextCursor - expect(mockGetNodesPage).toHaveBeenLastCalledWith(["YYZ"], { cursor: 2 }); + // second call paginates with the previous page's nextCursor; the map always requests neighbor ids + expect(mockGetNodesPage).toHaveBeenLastCalledWith(["YYZ"], { cursor: 2, neighbors: true }); }); it("stops after a single page when hasMore is false", async () => { diff --git a/tests/features/packets/payload-renderers.test.tsx b/tests/features/packets/payload-renderers.test.tsx index f84c9052..192829a1 100644 --- a/tests/features/packets/payload-renderers.test.tsx +++ b/tests/features/packets/payload-renderers.test.tsx @@ -46,6 +46,58 @@ describe("PayloadBreakdown — trace resolvedRoute overlay", () => { }); }); +describe("PayloadBreakdown — DISCOVER_REQ", () => { + // Backend emits DISCOVER as a top-level parsedPayload.type (not nested under CONTROL). + // See beacon-server internal/ingest/packet.go parsedDiscoverReq. + const reqPayload = { + type: "DISCOVER_REQ", + raw: "0b00", + prefixOnly: true, + typeFilter: 0x06, // bits 1 and 2 → ADV_TYPE 1 (ChatNode) + 2 (Repeater) + tag: "0a0b0c0d", + since: 1_700_000_000, // epoch seconds + }; + + it("decodes the typeFilter bitfield into device-role names", () => { + render(); + expect(screen.getByText("ChatNode")).toBeInTheDocument(); + expect(screen.getByText("Repeater")).toBeInTheDocument(); + }); + + it("renders the tag as a 0x-prefixed hex value", () => { + render(); + expect(screen.getByText("0x0A0B0C0D")).toBeInTheDocument(); + }); +}); + +describe("PayloadBreakdown — DISCOVER_RESP", () => { + // See beacon-server internal/ingest/packet.go parsedDiscoverResp. + const respPayload = { + type: "DISCOVER_RESP", + raw: "0b01", + nodeType: 2, + nodeTypeName: "repeater", + requestSnr: -4.5, // responder's node-to-node reading of the request, not observer reception + tag: "0a0b0c0d", + pubKey: "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", + pubKeyPrefixOnly: false, + }; + + it("renders the responder node type and its request SNR", () => { + render(); + expect(screen.getByText("repeater")).toBeInTheDocument(); + expect(screen.getByText(/-4\.50/)).toBeInTheDocument(); + }); + + it("labels a full public key vs. an 8-byte prefix", () => { + const { unmount } = render(); + expect(screen.getByText(/Public Key/)).toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText(/Key Prefix/)).toBeInTheDocument(); + }); +}); + describe("PayloadBreakdown — GROUP_TEXT decrypted channel message", () => { // Backend GetPacket enrichment nests decrypted:{sender,content,sentAt} (sentAt is epoch ms). // See beacon-server db/packets.go + internal/ingest/side_effects.go. From b5e2e5dcacba818bfa3d11f54a5201c68dacf51d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 6 Jul 2026 08:00:33 -0400 Subject: [PATCH 006/163] map: live mode dims nodes, flashes each packet's route --- src/features/map/packet-flow.ts | 96 +++++++-------------- src/features/map/types.ts | 9 +- src/features/map/useMapPacketFlow.ts | 115 ++++++++++++------------- tests/features/map/packet-flow.test.ts | 71 +++++++-------- 4 files changed, 122 insertions(+), 169 deletions(-) diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index 389a0aa2..57d82c2a 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -1,84 +1,48 @@ import type { Feature, FeatureCollection, Point } from "geojson"; import type { ResolvedHop } from "../../types/api"; -// Geometry helpers for the packet-flow animation. No maplibre import, so they stay unit-testable. +// Pure helpers for the live packet-flow highlight. No maplibre import, so they stay unit-testable. -// The [lng, lat] path a packet took, one point per resolved hop. Each hop uses its first located -// candidate; hops we can't place are dropped, so the route can come back with fewer than 2 points. -export function resolvedPathToRoute(resolvedPath: ResolvedHop[]): [number, number][] { - const route: [number, number][] = []; +// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. These are +// the nodes that light up when the packet is observed. +export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] { + const seen = new Set(); + const out: { id: string; lng: number; lat: number }[] = []; for (const hop of resolvedPath) { const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null); - if (node) route.push([node.longitude!, node.latitude!]); + if (node && !seen.has(node.id)) { + seen.add(node.id); + out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! }); + } } - return route; + return out; } -// Cumulative segment lengths (planar distance in degrees — accurate enough at mesh scale) so a pulse -// can be placed by fraction of total path length rather than fraction of hop count. -export function routeMetrics(coords: [number, number][]): { cumLengths: number[]; total: number } { - const cumLengths: number[] = [0]; - for (let i = 1; i < coords.length; i++) { - const [x0, y0] = coords[i - 1]!; - const [x1, y1] = coords[i]!; - cumLengths.push(cumLengths[i - 1]! + Math.hypot(x1 - x0, y1 - y0)); - } - return { cumLengths, total: cumLengths[cumLengths.length - 1] ?? 0 }; -} - -// Interpolated [lng, lat] at fraction t in [0,1] along the route, by cumulative length. -export function positionAt( - coords: [number, number][], - cumLengths: number[], - total: number, - t: number, -): [number, number] { - if (coords.length === 1 || total === 0) return coords[0]!; - const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; - const target = clamped * total; - let i = 1; - while (i < cumLengths.length - 1 && cumLengths[i]! < target) i++; - const segStart = cumLengths[i - 1]!; - const segEnd = cumLengths[i]!; - const segFrac = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart); - const [x0, y0] = coords[i - 1]!; - const [x1, y1] = coords[i]!; - return [x0 + (x1 - x0) * segFrac, y0 + (y1 - y0) * segFrac]; +// A node currently lit because it was on a recently-observed path. litAt is performance.now(). +export interface LitNode { + lng: number; + lat: number; + litAt: number; } -// One in-flight packet animation. cumLengths/total are precomputed (routeMetrics) so each frame is -// just an interpolation. -export interface Pulse { - id: number; - coords: [number, number][]; - cumLengths: number[]; - total: number; - startMs: number; - durationMs: number; -} - -export interface PulseFeatureProps { +export interface LitFeatureProps { opacity: number; } -// Elapsed fraction of a pulse's life; >1 once it has arrived (the caller expires those). -export function pulseProgress(pulse: Pulse, nowMs: number): number { - return pulse.durationMs <= 0 ? 1 : (nowMs - pulse.startMs) / pulse.durationMs; +// Opacity of a lit node: 1 the instant it lights, linearly down to 0 by fadeMs, clamped past that. +export function litOpacity(litAt: number, nowMs: number, fadeMs: number): number { + const t = (nowMs - litAt) / fadeMs; + if (t <= 0) return 1; + if (t >= 1) return 0; + return 1 - t; } -// Snapshot the live pulses as point features at their current positions. Opacity holds at 1 then -// eases out over the final quarter so a pulse fades as it reaches the last repeater. -export function buildPulseFC(pulses: Pulse[], nowMs: number): FeatureCollection { - const features: Feature[] = []; - for (const p of pulses) { - const t = pulseProgress(p, nowMs); - const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; - const opacity = clamped < 0.75 ? 1 : Math.max(0, 1 - (clamped - 0.75) / 0.25); - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, clamped) }, - properties: { opacity }, - }); - } +// Snapshot the currently-lit nodes as point features carrying their faded opacity. +export function buildLitFC(litNodes: LitNode[], nowMs: number, fadeMs: number): FeatureCollection { + const features: Feature[] = litNodes.map((n) => ({ + type: "Feature", + geometry: { type: "Point", coordinates: [n.lng, n.lat] }, + properties: { opacity: litOpacity(n.litAt, nowMs, fadeMs) }, + })); return { type: "FeatureCollection", features }; } diff --git a/src/features/map/types.ts b/src/features/map/types.ts index ab6c6724..e6dc1914 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -96,12 +96,11 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines"; export type NeighborLinesMode = "on" | "selected" | "off"; -// --- Live packet-flow animation --- +// --- Live packet-flow: dim every node, then flash a packet's resolved-path nodes and fade them out --- export const PACKET_FLOW_SOURCE_ID = "packet-flow"; -export const PACKET_FLOW_LAYER_ID = "packet-flow-pulses"; // circle layer on top (moving pulse) -export const PACKET_FLOW_MAX_PULSES = 60; // cap concurrent animations; drop the oldest past this -export const PACKET_FLOW_SEGMENT_MS = 700; // pulse travel time per hop segment -export const PACKET_FLOW_DEDUP_MS = 3000; // collapse repeat observations of one packetHash within this window +export const PACKET_FLOW_LAYER_ID = "packet-flow-lit"; // bright highlight drawn over the route's nodes +export const PACKET_FLOW_FADE_MS = 4000; // a lit node fades from full opacity back to nothing over this +export const LIVE_DIM_OPACITY = 0.1; // base node + cluster opacity while Live mode is on export const CLUSTER_RADIUS = 50; // px // Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 8d51b4d9..ffc99c49 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -1,25 +1,31 @@ import { useCallback, useEffect, useRef } from "react"; -import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification } from "maplibre-gl"; +import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, ExpressionSpecification } from "maplibre-gl"; import type { FeatureCollection } from "geojson"; import type { WsManager } from "../../api/ws-manager"; -import { resolvedPathToRoute, routeMetrics, buildPulseFC, pulseProgress, type Pulse } from "./packet-flow"; +import { resolvedPathNodes, buildLitFC, type LitNode } from "./packet-flow"; import { PACKET_FLOW_SOURCE_ID, PACKET_FLOW_LAYER_ID, - PACKET_FLOW_MAX_PULSES, - PACKET_FLOW_SEGMENT_MS, - PACKET_FLOW_DEDUP_MS, + PACKET_FLOW_FADE_MS, + LIVE_DIM_OPACITY, + NODES_POINT_LAYER_ID, + NODES_CLUSTER_LAYER_ID, + NODE_LABEL_MIN_ZOOM, } from "./types"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; +// node labels normally fade in past NODE_LABEL_MIN_ZOOM — restored when Live turns off +const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; + function paletteVar(name: string, fallback: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } -// Live "packets moving between repeaters" overlay. While enabled it turns on resolvedPath over the -// WS (setResolvePath) and animates a pulse along each observed packet's path. Geometry is pure -// (packet-flow.ts); here we own the maplibre source, the rAF loop, and the subscription. +// Live mode: dim every node/cluster to near-invisible, then flash the nodes on each observed packet's +// resolved path to full opacity and fade them back over PACKET_FLOW_FADE_MS. Enabling it also opts the +// WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the maplibre +// highlight layer, the dimming, the rAF fade loop, and the subscription. export function useMapPacketFlow( mapRef: React.RefObject, isReady: boolean, @@ -28,47 +34,46 @@ export function useMapPacketFlow( themeKey: string, resetKey: string, ) { - const pulsesRef = useRef([]); + const litRef = useRef>(new Map()); const rafRef = useRef(null); - const nextIdRef = useRef(0); - const recentRef = useRef>(new Map()); - // start the rAF loop if it's idle. The frame reschedules itself until the last pulse expires, then - // leaves rafRef null so we stop instead of spinning on an empty source. + // fade loop: recompute each lit node's opacity, drop the fully-faded, stop once none remain const startLoop = useCallback(() => { if (rafRef.current != null) return; function frame() { - const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; const now = performance.now(); - pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1); - if (src) src.setData(buildPulseFC(pulsesRef.current, now)); - rafRef.current = pulsesRef.current.length > 0 ? requestAnimationFrame(frame) : null; + for (const [id, n] of litRef.current) { + if (now - n.litAt >= PACKET_FLOW_FADE_MS) litRef.current.delete(id); + } + const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; + if (src) src.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS)); + rafRef.current = litRef.current.size > 0 ? requestAnimationFrame(frame) : null; } rafRef.current = requestAnimationFrame(frame); }, [mapRef]); - // build the pulse source + layer; re-adds itself after every style switch, re-tints on theme change + // build the highlight source + layer (a bright glow over lit nodes); re-adds after a style switch useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; - const accent = paletteVar("--palette-primary", "#3B82F6"); if (!map.getSource(PACKET_FLOW_SOURCE_ID)) { map.addSource(PACKET_FLOW_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); } if (!map.getLayer(PACKET_FLOW_LAYER_ID)) { - // no beforeId: draw on top of the node markers so the moving pulse stays visible + // no beforeId: draw on top so the highlight pops over the dimmed markers map.addLayer({ id: PACKET_FLOW_LAYER_ID, type: "circle", source: PACKET_FLOW_SOURCE_ID, paint: { - "circle-radius": 5, + "circle-radius": 9, "circle-color": accent, - "circle-opacity": ["get", "opacity"], - "circle-stroke-width": 1.5, + "circle-opacity": ["*", ["get", "opacity"], 0.85], + "circle-blur": 0.35, + "circle-stroke-width": 2, "circle-stroke-color": accent, - "circle-stroke-opacity": ["*", ["get", "opacity"], 0.5], + "circle-stroke-opacity": ["get", "opacity"], }, } as CircleLayerSpecification); } @@ -76,54 +81,47 @@ export function useMapPacketFlow( map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent); }, [mapRef, isReady, themeKey]); - // connection-wide resolvePath toggle: on when enabled, off on disable/unmount + // connection-wide resolvePath toggle: on while enabled, off otherwise useEffect(() => { wsManager.setResolvePath(enabled); return () => wsManager.setResolvePath(false); }, [enabled, wsManager]); - // feed observed resolved paths into new pulses; tear the animation down when disabled + // dim (or restore) the base node + cluster layers. Keyed on themeKey too so it re-applies after + // useMapNodes rebuilds its layers on a theme/style change (that hook runs first, resetting opacity). + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const iconOpacity = enabled ? LIVE_DIM_OPACITY : 1; + for (const id of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID]) { + if (map.getLayer(id)) map.setPaintProperty(id, "icon-opacity", iconOpacity); + } + if (map.getLayer(NODES_POINT_LAYER_ID)) { + map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY); + } + if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1); + } + }, [mapRef, isReady, enabled, themeKey]); + + // light up each observed packet's resolved-path nodes; tear down when disabled useEffect(() => { if (!enabled) return; - const map = mapRef.current; // stable for the component's life; used to clear the source on cleanup + const map = mapRef.current; + const lit = litRef.current; // stable Map for the component's life; used in the cleanup too const unsub = wsManager.onPacketObservation((data) => { const resolved = data.observation?.resolvedPath; if (!resolved || resolved.length === 0) return; - + const nodes = resolvedPathNodes(resolved); + if (nodes.length === 0) return; const now = performance.now(); - // many observers report the same packet — animate it once per dedup window - const seenAt = recentRef.current.get(data.packetHash); - if (seenAt != null && now - seenAt < PACKET_FLOW_DEDUP_MS) return; - - const coords = resolvedPathToRoute(resolved); - if (coords.length < 2) return; // nothing to draw between - const { cumLengths, total } = routeMetrics(coords); - if (total === 0) return; - - // record only after we know this observation produced a pulse, so a partially-resolved report - // doesn't suppress a later fully-resolved one for the same packet - recentRef.current.set(data.packetHash, now); - for (const [hash, ts] of recentRef.current) { - if (now - ts > PACKET_FLOW_DEDUP_MS) recentRef.current.delete(hash); - } - - pulsesRef.current.push({ - id: nextIdRef.current++, - coords, - cumLengths, - total, - startMs: now, - durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS, - }); - if (pulsesRef.current.length > PACKET_FLOW_MAX_PULSES) { - pulsesRef.current.splice(0, pulsesRef.current.length - PACKET_FLOW_MAX_PULSES); - } + for (const n of nodes) lit.set(n.id, { lng: n.lng, lat: n.lat, litAt: now }); startLoop(); }); return () => { unsub(); - pulsesRef.current = []; + lit.clear(); if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; @@ -133,10 +131,9 @@ export function useMapPacketFlow( }; }, [enabled, wsManager, mapRef, startLoop]); - // clear in-flight pulses when the region changes (their geometry came from the old dataset) + // clear highlights when the region changes (those nodes came from the old dataset) useEffect(() => { - pulsesRef.current = []; - recentRef.current.clear(); + litRef.current.clear(); const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; src?.setData(EMPTY_FC); }, [resetKey, mapRef]); diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index 88260411..9baebc15 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -1,55 +1,48 @@ import { describe, it, expect } from "vitest"; -import { resolvedPathToRoute, routeMetrics, positionAt, buildPulseFC } from "../../../src/features/map/packet-flow"; +import { resolvedPathNodes, litOpacity, buildLitFC } from "../../../src/features/map/packet-flow"; import type { ResolvedHop } from "../../../src/types/api"; -// a high-confidence hop resolved to one located node at [lng, lat] -function hop(lng: number, lat: number): ResolvedHop { - return { confidence: "high", nodes: [{ id: "n", publicKey: "pk", longitude: lng, latitude: lat }] }; +// a high-confidence hop resolved to one located node +function hop(id: string, lng: number, lat: number): ResolvedHop { + return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }; } -describe("resolvedPathToRoute", () => { - it("keeps located hops in order as [lng, lat] and drops coordless hops", () => { - const path: ResolvedHop[] = [hop(-75, 45), { confidence: "none", nodes: [] }, hop(-76, 46)]; - expect(resolvedPathToRoute(path)).toEqual([[-75, 45], [-76, 46]]); +describe("resolvedPathNodes", () => { + it("returns each hop's first located node as { id, lng, lat }, in order", () => { + const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("b", -76, 46)]; + expect(resolvedPathNodes(path)).toEqual([ + { id: "a", lng: -75, lat: 45 }, + { id: "b", lng: -76, lat: 46 }, + ]); }); - it("returns fewer than 2 points when the path has no drawable geometry", () => { - expect(resolvedPathToRoute([{ confidence: "none", nodes: [] }])).toEqual([]); - }); -}); - -describe("routeMetrics + positionAt", () => { - it("interpolates endpoints and the midpoint of a straight segment", () => { - const coords: [number, number][] = [[0, 0], [10, 0]]; - const { cumLengths, total } = routeMetrics(coords); - expect(total).toBe(10); - expect(positionAt(coords, cumLengths, total, 0)).toEqual([0, 0]); - expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 0]); - expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([5, 0]); + it("dedupes a node that appears on more than one hop", () => { + const path: ResolvedHop[] = [hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)]; + expect(resolvedPathNodes(path).map((n) => n.id)).toEqual(["a", "b"]); }); - it("walks the correct segment on a multi-hop route", () => { - const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; - const { cumLengths, total } = routeMetrics(coords); - expect(total).toBe(20); - expect(positionAt(coords, cumLengths, total, 0.5)).toEqual([10, 0]); // the middle vertex - expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]); + it("skips hops with no located candidate", () => { + const path: ResolvedHop[] = [{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }]; + expect(resolvedPathNodes(path)).toEqual([]); }); }); -describe("buildPulseFC", () => { - const coords: [number, number][] = [[0, 0], [10, 0]]; - const { cumLengths, total } = routeMetrics(coords); - const pulse = { id: 1, coords, cumLengths, total, startMs: 1000, durationMs: 1000 }; - - it("places each pulse at its current position along the route", () => { - const fc = buildPulseFC([pulse], 1500); // halfway through - expect(fc.features).toHaveLength(1); - expect(fc.features[0]!.geometry.coordinates).toEqual([5, 0]); +describe("litOpacity", () => { + it("is full at the moment lit and eases to zero by fadeMs", () => { + expect(litOpacity(1000, 1000, 4000)).toBe(1); // just lit + expect(litOpacity(1000, 3000, 4000)).toBeCloseTo(0.5); // halfway + expect(litOpacity(1000, 5000, 4000)).toBe(0); // fully faded + expect(litOpacity(1000, 9000, 4000)).toBe(0); // past its life, clamped }); +}); - it("keeps full opacity early and fades to zero at the end", () => { - expect(buildPulseFC([pulse], 1500).features[0]!.properties.opacity).toBe(1); // t=0.5 - expect(buildPulseFC([pulse], 2000).features[0]!.properties.opacity).toBe(0); // t=1 +describe("buildLitFC", () => { + it("emits one point feature per lit node with its current opacity", () => { + const lit = [{ lng: -75, lat: 45, litAt: 1000 }, { lng: -76, lat: 46, litAt: 3000 }]; + const fc = buildLitFC(lit, 3000, 4000); + expect(fc.features).toHaveLength(2); + expect(fc.features[0]!.geometry.coordinates).toEqual([-75, 45]); + expect(fc.features[0]!.properties.opacity).toBeCloseTo(0.5); // lit at 1000, now 3000 + expect(fc.features[1]!.properties.opacity).toBe(1); // just lit }); }); From a0736545d8b5264c80ec98536c83860eaef1ab25 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 6 Jul 2026 08:13:03 -0400 Subject: [PATCH 007/163] map: shoot a comet along each packet's route in live mode --- src/features/map/packet-flow.ts | 74 ++++++++++++++++++++ src/features/map/types.ts | 5 +- src/features/map/useMapPacketFlow.ts | 96 ++++++++++++++++++++------ tests/features/map/packet-flow.test.ts | 35 +++++++++- 4 files changed, 188 insertions(+), 22 deletions(-) diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index 57d82c2a..798d2396 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -46,3 +46,77 @@ export function buildLitFC(litNodes: LitNode[], nowMs: number, fadeMs: number): })); return { type: "FeatureCollection", features }; } + +// --- Comet: a bright head that shoots along the route, trailing a fading streak --- + +// Cumulative segment lengths (planar degrees — fine at mesh scale) so the head can be placed by +// fraction of total path length rather than fraction of hop count. +export function routeMetrics(coords: [number, number][]): { cumLengths: number[]; total: number } { + const cumLengths: number[] = [0]; + for (let i = 1; i < coords.length; i++) { + const [x0, y0] = coords[i - 1]!; + const [x1, y1] = coords[i]!; + cumLengths.push(cumLengths[i - 1]! + Math.hypot(x1 - x0, y1 - y0)); + } + return { cumLengths, total: cumLengths[cumLengths.length - 1] ?? 0 }; +} + +// Interpolated [lng, lat] at fraction t in [0,1] along the route, by cumulative length. +export function positionAt(coords: [number, number][], cumLengths: number[], total: number, t: number): [number, number] { + if (coords.length === 1 || total === 0) return coords[0]!; + const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; + const target = clamped * total; + let i = 1; + while (i < cumLengths.length - 1 && cumLengths[i]! < target) i++; + const segStart = cumLengths[i - 1]!; + const segEnd = cumLengths[i]!; + const segFrac = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart); + const [x0, y0] = coords[i - 1]!; + const [x1, y1] = coords[i]!; + return [x0 + (x1 - x0) * segFrac, y0 + (y1 - y0) * segFrac]; +} + +// One in-flight comet. cumLengths/total are precomputed (routeMetrics) so each frame is an interpolation. +export interface Pulse { + id: number; + coords: [number, number][]; + cumLengths: number[]; + total: number; + startMs: number; + durationMs: number; +} + +export interface CometFeatureProps { + opacity: number; + head: number; // 1 for the bright head, 0 for trail points +} + +// Elapsed fraction of a comet's travel; >1 once it has arrived (the caller expires those). +export function pulseProgress(pulse: Pulse, nowMs: number): number { + return pulse.durationMs <= 0 ? 1 : (nowMs - pulse.startMs) / pulse.durationMs; +} + +const COMET_TRAIL = 5; // trailing points behind the head +const COMET_STEP = 0.045; // spacing between trail points, as a fraction of travel + +// The comet head + a short trail behind it, each a point with its own opacity. The whole comet eases +// out over the final stretch so it fades as it reaches the last repeater. +export function buildCometFC(pulses: Pulse[], nowMs: number): FeatureCollection { + const features: Feature[] = []; + for (const p of pulses) { + const t = pulseProgress(p, nowMs); + const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; + const life = clamped < 0.85 ? 1 : Math.max(0, 1 - (clamped - 0.85) / 0.15); + for (let k = 0; k <= COMET_TRAIL; k++) { + const tk = clamped - k * COMET_STEP; + if (tk < 0) break; + const trail = 1 - k / (COMET_TRAIL + 1); // head brightest, tail dimmest + features.push({ + type: "Feature", + geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, tk) }, + properties: { opacity: life * trail, head: k === 0 ? 1 : 0 }, + }); + } + } + return { type: "FeatureCollection", features }; +} diff --git a/src/features/map/types.ts b/src/features/map/types.ts index e6dc1914..1cafc306 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -96,10 +96,13 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines"; export type NeighborLinesMode = "on" | "selected" | "off"; -// --- Live packet-flow: dim every node, then flash a packet's resolved-path nodes and fade them out --- +// --- Live packet-flow: dim every node, flash the resolved-path nodes, and shoot a comet along the route --- export const PACKET_FLOW_SOURCE_ID = "packet-flow"; export const PACKET_FLOW_LAYER_ID = "packet-flow-lit"; // bright highlight drawn over the route's nodes +export const PACKET_FLOW_COMET_SOURCE_ID = "packet-flow-comet"; +export const PACKET_FLOW_COMET_LAYER_ID = "packet-flow-comet"; // moving comet head + trail, on top export const PACKET_FLOW_FADE_MS = 4000; // a lit node fades from full opacity back to nothing over this +export const PACKET_FLOW_SEGMENT_MS = 650; // comet travel time per hop segment export const LIVE_DIM_OPACITY = 0.1; // base node + cluster opacity while Live mode is on export const CLUSTER_RADIUS = 50; // px diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index ffc99c49..2e32cbea 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -2,11 +2,14 @@ import { useCallback, useEffect, useRef } from "react"; import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, ExpressionSpecification } from "maplibre-gl"; import type { FeatureCollection } from "geojson"; import type { WsManager } from "../../api/ws-manager"; -import { resolvedPathNodes, buildLitFC, type LitNode } from "./packet-flow"; +import { resolvedPathNodes, buildLitFC, routeMetrics, buildCometFC, pulseProgress, type LitNode, type Pulse } from "./packet-flow"; import { PACKET_FLOW_SOURCE_ID, PACKET_FLOW_LAYER_ID, + PACKET_FLOW_COMET_SOURCE_ID, + PACKET_FLOW_COMET_LAYER_ID, PACKET_FLOW_FADE_MS, + PACKET_FLOW_SEGMENT_MS, LIVE_DIM_OPACITY, NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID, @@ -22,10 +25,10 @@ function paletteVar(name: string, fallback: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } -// Live mode: dim every node/cluster to near-invisible, then flash the nodes on each observed packet's -// resolved path to full opacity and fade them back over PACKET_FLOW_FADE_MS. Enabling it also opts the -// WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the maplibre -// highlight layer, the dimming, the rAF fade loop, and the subscription. +// Live mode: dim every node/cluster to near-invisible; on each observed packet, flash its resolved-path +// nodes to full opacity (fading over PACKET_FLOW_FADE_MS) and shoot a comet along the route. Enabling +// it opts the WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the +// maplibre layers, the dimming, the rAF loop, and the subscription. export function useMapPacketFlow( mapRef: React.RefObject, isReady: boolean, @@ -35,33 +38,44 @@ export function useMapPacketFlow( resetKey: string, ) { const litRef = useRef>(new Map()); + const pulsesRef = useRef([]); + const nextIdRef = useRef(0); const rafRef = useRef(null); - // fade loop: recompute each lit node's opacity, drop the fully-faded, stop once none remain + // one animation frame: fade the lit nodes and advance the comets; keep going while either has work const startLoop = useCallback(() => { if (rafRef.current != null) return; function frame() { + const map = mapRef.current; const now = performance.now(); + for (const [id, n] of litRef.current) { if (now - n.litAt >= PACKET_FLOW_FADE_MS) litRef.current.delete(id); } - const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; - if (src) src.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS)); - rafRef.current = litRef.current.size > 0 ? requestAnimationFrame(frame) : null; + const litSrc = map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; + if (litSrc) litSrc.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS)); + + pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1); + const cometSrc = map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined; + if (cometSrc) cometSrc.setData(buildCometFC(pulsesRef.current, now)); + + const busy = litRef.current.size > 0 || pulsesRef.current.length > 0; + rafRef.current = busy ? requestAnimationFrame(frame) : null; } rafRef.current = requestAnimationFrame(frame); }, [mapRef]); - // build the highlight source + layer (a bright glow over lit nodes); re-adds after a style switch + // build both layers: the node-highlight glow and the comet (head + trail). Re-adds after a style switch. useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; const accent = paletteVar("--palette-primary", "#3B82F6"); + if (!map.getSource(PACKET_FLOW_SOURCE_ID)) { map.addSource(PACKET_FLOW_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); } if (!map.getLayer(PACKET_FLOW_LAYER_ID)) { - // no beforeId: draw on top so the highlight pops over the dimmed markers + // no beforeId: draw over the dimmed markers map.addLayer({ id: PACKET_FLOW_LAYER_ID, type: "circle", @@ -79,6 +93,25 @@ export function useMapPacketFlow( } map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-color", accent); map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent); + + // comet on top of the glow + if (!map.getSource(PACKET_FLOW_COMET_SOURCE_ID)) { + map.addSource(PACKET_FLOW_COMET_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + if (!map.getLayer(PACKET_FLOW_COMET_LAYER_ID)) { + map.addLayer({ + id: PACKET_FLOW_COMET_LAYER_ID, + type: "circle", + source: PACKET_FLOW_COMET_SOURCE_ID, + paint: { + "circle-radius": ["case", ["==", ["get", "head"], 1], 5, 3], + "circle-color": accent, + "circle-opacity": ["get", "opacity"], + "circle-blur": 0.5, + }, + } as CircleLayerSpecification); + } + map.setPaintProperty(PACKET_FLOW_COMET_LAYER_ID, "circle-color", accent); }, [mapRef, isReady, themeKey]); // connection-wide resolvePath toggle: on while enabled, off otherwise @@ -104,7 +137,7 @@ export function useMapPacketFlow( } }, [mapRef, isReady, enabled, themeKey]); - // light up each observed packet's resolved-path nodes; tear down when disabled + // per observed packet: flash its route's nodes and launch a comet along the route useEffect(() => { if (!enabled) return; const map = mapRef.current; @@ -115,30 +148,49 @@ export function useMapPacketFlow( const nodes = resolvedPathNodes(resolved); if (nodes.length === 0) return; const now = performance.now(); + for (const n of nodes) lit.set(n.id, { lng: n.lng, lat: n.lat, litAt: now }); + + if (nodes.length >= 2) { + const coords = nodes.map((n) => [n.lng, n.lat] as [number, number]); + const { cumLengths, total } = routeMetrics(coords); + if (total > 0) { + pulsesRef.current.push({ + id: nextIdRef.current++, + coords, + cumLengths, + total, + startMs: now, + durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS, + }); + } + } startLoop(); }); return () => { unsub(); lit.clear(); + pulsesRef.current = []; if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); rafRef.current = null; } - const src = map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; - src?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); }; }, [enabled, wsManager, mapRef, startLoop]); - // clear highlights when the region changes (those nodes came from the old dataset) + // clear highlights + comets when the region changes (they came from the old dataset) useEffect(() => { litRef.current.clear(); - const src = mapRef.current?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; - src?.setData(EMPTY_FC); + pulsesRef.current = []; + const map = mapRef.current; + (map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); }, [resetKey, mapRef]); - // remove the layer + source on unmount (runs before useMapLibre's map.remove()) + // remove both layers + sources on unmount (runs before useMapLibre's map.remove()) useEffect(() => { const map = mapRef.current; return () => { @@ -146,8 +198,12 @@ export function useMapPacketFlow( rafRef.current = null; if (!map) return; try { - if (map.getLayer(PACKET_FLOW_LAYER_ID)) map.removeLayer(PACKET_FLOW_LAYER_ID); - if (map.getSource(PACKET_FLOW_SOURCE_ID)) map.removeSource(PACKET_FLOW_SOURCE_ID); + for (const id of [PACKET_FLOW_LAYER_ID, PACKET_FLOW_COMET_LAYER_ID]) { + if (map.getLayer(id)) map.removeLayer(id); + } + for (const id of [PACKET_FLOW_SOURCE_ID, PACKET_FLOW_COMET_SOURCE_ID]) { + if (map.getSource(id)) map.removeSource(id); + } } catch { // map may already be torn down } diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index 9baebc15..d8e6ea08 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { resolvedPathNodes, litOpacity, buildLitFC } from "../../../src/features/map/packet-flow"; +import { resolvedPathNodes, litOpacity, buildLitFC, routeMetrics, positionAt, buildCometFC } from "../../../src/features/map/packet-flow"; import type { ResolvedHop } from "../../../src/types/api"; // a high-confidence hop resolved to one located node @@ -46,3 +46,36 @@ describe("buildLitFC", () => { expect(fc.features[1]!.properties.opacity).toBe(1); // just lit }); }); + +describe("routeMetrics + positionAt", () => { + it("measures cumulative length and interpolates along the route", () => { + const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; + const { cumLengths, total } = routeMetrics(coords); + expect(total).toBe(20); + expect(positionAt(coords, cumLengths, total, 0)).toEqual([0, 0]); + expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 10]); + expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]); + }); +}); + +describe("buildCometFC", () => { + const coords: [number, number][] = [[0, 0], [10, 0]]; + const { cumLengths, total } = routeMetrics(coords); + const pulse = { id: 1, coords, cumLengths, total, startMs: 0, durationMs: 1000 }; + + it("emits a bright head at the current position plus a dimmer trail behind it", () => { + const fc = buildCometFC([pulse], 500); // halfway + const head = fc.features[0]!; + expect(head.properties.head).toBe(1); + expect(head.geometry.coordinates[0]).toBeCloseTo(5, 5); // midpoint of the segment + expect(fc.features.length).toBeGreaterThan(1); // has a trail + expect(fc.features[1]!.properties.head).toBe(0); + expect(fc.features[1]!.properties.opacity).toBeLessThan(head.properties.opacity); + }); + + it("fades the comet out as it arrives at the end", () => { + const near = buildCometFC([pulse], 990).features[0]!.properties.opacity; // ~arrived + const mid = buildCometFC([pulse], 400).features[0]!.properties.opacity; + expect(near).toBeLessThan(mid); + }); +}); From 105e68ec0474061d33a322bfecc862e603670902 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 7 Jul 2026 22:49:48 -0400 Subject: [PATCH 008/163] =?UTF-8?q?map:=20rework=20live=20mode=20=E2=80=94?= =?UTF-8?q?=20packet=20dot,=20dashed=20trail,=20per-hop=20flash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/map/packet-flow.ts | 129 +++--------- src/features/map/types.ts | 21 +- src/features/map/useMapNodes.ts | 2 + src/features/map/useMapPacketFlow.ts | 261 ++++++++++++++----------- tests/features/map/packet-flow.test.ts | 78 ++------ 5 files changed, 209 insertions(+), 282 deletions(-) diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index 798d2396..23208ca9 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -1,10 +1,10 @@ -import type { Feature, FeatureCollection, Point } from "geojson"; import type { ResolvedHop } from "../../types/api"; -// Pure helpers for the live packet-flow highlight. No maplibre import, so they stay unit-testable. +// Pure helpers for the live packet-flow animation (modelled on MeshMapper's LiveViz). No maplibre +// import, so they stay unit-testable; the hook owns the layers, the rAF loop, and the node flashes. -// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. These are -// the nodes that light up when the packet is observed. +// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. The dot +// rides these coords and flashes each node as it crosses. export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] { const seen = new Set(); const out: { id: string; lng: number; lat: number }[] = []; @@ -18,105 +18,24 @@ export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; ln return out; } -// A node currently lit because it was on a recently-observed path. litAt is performance.now(). -export interface LitNode { - lng: number; - lat: number; - litAt: number; -} - -export interface LitFeatureProps { - opacity: number; -} - -// Opacity of a lit node: 1 the instant it lights, linearly down to 0 by fadeMs, clamped past that. -export function litOpacity(litAt: number, nowMs: number, fadeMs: number): number { - const t = (nowMs - litAt) / fadeMs; - if (t <= 0) return 1; - if (t >= 1) return 0; - return 1 - t; -} - -// Snapshot the currently-lit nodes as point features carrying their faded opacity. -export function buildLitFC(litNodes: LitNode[], nowMs: number, fadeMs: number): FeatureCollection { - const features: Feature[] = litNodes.map((n) => ({ - type: "Feature", - geometry: { type: "Point", coordinates: [n.lng, n.lat] }, - properties: { opacity: litOpacity(n.litAt, nowMs, fadeMs) }, - })); - return { type: "FeatureCollection", features }; -} - -// --- Comet: a bright head that shoots along the route, trailing a fading streak --- - -// Cumulative segment lengths (planar degrees — fine at mesh scale) so the head can be placed by -// fraction of total path length rather than fraction of hop count. -export function routeMetrics(coords: [number, number][]): { cumLengths: number[]; total: number } { - const cumLengths: number[] = [0]; - for (let i = 1; i < coords.length; i++) { - const [x0, y0] = coords[i - 1]!; - const [x1, y1] = coords[i]!; - cumLengths.push(cumLengths[i - 1]! + Math.hypot(x1 - x0, y1 - y0)); - } - return { cumLengths, total: cumLengths[cumLengths.length - 1] ?? 0 }; -} - -// Interpolated [lng, lat] at fraction t in [0,1] along the route, by cumulative length. -export function positionAt(coords: [number, number][], cumLengths: number[], total: number, t: number): [number, number] { - if (coords.length === 1 || total === 0) return coords[0]!; - const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; - const target = clamped * total; - let i = 1; - while (i < cumLengths.length - 1 && cumLengths[i]! < target) i++; - const segStart = cumLengths[i - 1]!; - const segEnd = cumLengths[i]!; - const segFrac = segEnd === segStart ? 0 : (target - segStart) / (segEnd - segStart); - const [x0, y0] = coords[i - 1]!; - const [x1, y1] = coords[i]!; - return [x0 + (x1 - x0) * segFrac, y0 + (y1 - y0) * segFrac]; -} - -// One in-flight comet. cumLengths/total are precomputed (routeMetrics) so each frame is an interpolation. -export interface Pulse { - id: number; - coords: [number, number][]; - cumLengths: number[]; - total: number; - startMs: number; - durationMs: number; -} - -export interface CometFeatureProps { - opacity: number; - head: number; // 1 for the bright head, 0 for trail points -} - -// Elapsed fraction of a comet's travel; >1 once it has arrived (the caller expires those). -export function pulseProgress(pulse: Pulse, nowMs: number): number { - return pulse.durationMs <= 0 ? 1 : (nowMs - pulse.startMs) / pulse.durationMs; -} - -const COMET_TRAIL = 5; // trailing points behind the head -const COMET_STEP = 0.045; // spacing between trail points, as a fraction of travel - -// The comet head + a short trail behind it, each a point with its own opacity. The whole comet eases -// out over the final stretch so it fades as it reaches the last repeater. -export function buildCometFC(pulses: Pulse[], nowMs: number): FeatureCollection { - const features: Feature[] = []; - for (const p of pulses) { - const t = pulseProgress(p, nowMs); - const clamped = t <= 0 ? 0 : t >= 1 ? 1 : t; - const life = clamped < 0.85 ? 1 : Math.max(0, 1 - (clamped - 0.85) / 0.15); - for (let k = 0; k <= COMET_TRAIL; k++) { - const tk = clamped - k * COMET_STEP; - if (tk < 0) break; - const trail = 1 - k / (COMET_TRAIL + 1); // head brightest, tail dimmest - features.push({ - type: "Feature", - geometry: { type: "Point", coordinates: positionAt(p.coords, p.cumLengths, p.total, tk) }, - properties: { opacity: life * trail, head: k === 0 ? 1 : 0 }, - }); - } - } - return { type: "FeatureCollection", features }; +// Position at fractional hop index t (0 .. coords.length-1): the integer part picks the hop segment, +// the fraction interpolates within it. Constant time per hop, so long and short hops feel the same. +export function posAtHop(coords: [number, number][], t: number): [number, number] { + const n = coords.length - 1; + if (t <= 0) return coords[0]!; + if (t >= n) return coords[n]!; + const s = Math.floor(t); + const f = t - s; + const a = coords[s]!; + const b = coords[s + 1]!; + return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f]; +} + +// The polyline the dot has traced so far: every hop coord up to the head, plus the head position. +export function trailCoords(coords: [number, number][], headT: number): [number, number][] { + const seg = Math.floor(headT); + const out: [number, number][] = []; + for (let s = 0; s <= seg && s < coords.length; s++) out.push(coords[s]!); + out.push(posAtHop(coords, headT)); + return out; } diff --git a/src/features/map/types.ts b/src/features/map/types.ts index 1cafc306..b4009cac 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -96,14 +96,19 @@ export const NEIGHBORS_LINE_LAYER_ID = "neighbor-lines"; // line layer drawn ben export const MAP_NEIGHBOR_LINES_STORAGE_KEY = "beacon-map-neighbor-lines"; export type NeighborLinesMode = "on" | "selected" | "off"; -// --- Live packet-flow: dim every node, flash the resolved-path nodes, and shoot a comet along the route --- -export const PACKET_FLOW_SOURCE_ID = "packet-flow"; -export const PACKET_FLOW_LAYER_ID = "packet-flow-lit"; // bright highlight drawn over the route's nodes -export const PACKET_FLOW_COMET_SOURCE_ID = "packet-flow-comet"; -export const PACKET_FLOW_COMET_LAYER_ID = "packet-flow-comet"; // moving comet head + trail, on top -export const PACKET_FLOW_FADE_MS = 4000; // a lit node fades from full opacity back to nothing over this -export const PACKET_FLOW_SEGMENT_MS = 650; // comet travel time per hop segment -export const LIVE_DIM_OPACITY = 0.1; // base node + cluster opacity while Live mode is on +// --- Live packet-flow (modelled on MeshMapper's "LiveViz"): dim every node, then per packet shoot an +// orange dot along its real hop path with a fading dashed trail, flashing each node as the dot crosses --- +export const PACKET_FLOW_TRAIL_SOURCE_ID = "packet-flow-trail"; +export const PACKET_FLOW_TRAIL_LAYER_ID = "packet-flow-trail"; // dashed line tracing behind the dot +export const PACKET_FLOW_DOT_SOURCE_ID = "packet-flow-dot"; +export const PACKET_FLOW_DOT_HALO_LAYER_ID = "packet-flow-dot-halo"; // dark halo behind the dot +export const PACKET_FLOW_DOT_LAYER_ID = "packet-flow-dot"; // the moving packet dot +export const PACKET_FLOW_COLOR = "#ff6b35"; // warm orange, distinct from the node palette so packets pop +export const PACKET_FLOW_HOP_MS = 480; // ms the dot takes to cross one hop segment +export const PACKET_FLOW_FLASH_MS = 900; // a crossed node's flash decays back to dim over this +export const PACKET_FLOW_TRAIL_FADE_MS = 1000; // the dashed trail fades once the dot reaches the end +export const PACKET_FLOW_MAX = 120; // cap on concurrent packet animations (busy-feed guard) +export const LIVE_DIM_OPACITY = 0.18; // idle node/cluster opacity while Live mode is on export const CLUSTER_RADIUS = 50; // px // Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index 545811d4..951479c5 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -162,6 +162,8 @@ export function useMapNodes( cluster: clustered, clusterRadius: CLUSTER_RADIUS, clusterMaxZoom: CLUSTER_MAX_ZOOM, + // promote the node id so live packet-flow can flash individual nodes via feature-state + promoteId: "id", }); } diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 2e32cbea..2c4d3cde 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -1,16 +1,21 @@ import { useCallback, useEffect, useRef } from "react"; -import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, ExpressionSpecification } from "maplibre-gl"; -import type { FeatureCollection } from "geojson"; +import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl"; +import type { Feature, FeatureCollection, Point, LineString } from "geojson"; import type { WsManager } from "../../api/ws-manager"; -import { resolvedPathNodes, buildLitFC, routeMetrics, buildCometFC, pulseProgress, type LitNode, type Pulse } from "./packet-flow"; +import { resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; import { - PACKET_FLOW_SOURCE_ID, - PACKET_FLOW_LAYER_ID, - PACKET_FLOW_COMET_SOURCE_ID, - PACKET_FLOW_COMET_LAYER_ID, - PACKET_FLOW_FADE_MS, - PACKET_FLOW_SEGMENT_MS, + PACKET_FLOW_TRAIL_SOURCE_ID, + PACKET_FLOW_TRAIL_LAYER_ID, + PACKET_FLOW_DOT_SOURCE_ID, + PACKET_FLOW_DOT_HALO_LAYER_ID, + PACKET_FLOW_DOT_LAYER_ID, + PACKET_FLOW_COLOR, + PACKET_FLOW_HOP_MS, + PACKET_FLOW_FLASH_MS, + PACKET_FLOW_TRAIL_FADE_MS, + PACKET_FLOW_MAX, LIVE_DIM_OPACITY, + NODES_SOURCE_ID, NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID, NODE_LABEL_MIN_ZOOM, @@ -20,15 +25,21 @@ const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; // node labels normally fade in past NODE_LABEL_MIN_ZOOM — restored when Live turns off const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; - -function paletteVar(name: string, fallback: string): string { - return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; +// dimmed to the idle baseline, but lifted to full for a node currently flashing (feature-state glow 0..1) +const LIVE_ICON_OPACITY: ExpressionSpecification = ["max", LIVE_DIM_OPACITY, ["coalesce", ["feature-state", "glow"], 0]]; + +// one packet riding its hop path once +interface Flow { + coords: [number, number][]; + ids: (string | null)[]; + start: number; + lastNode: number; } -// Live mode: dim every node/cluster to near-invisible; on each observed packet, flash its resolved-path -// nodes to full opacity (fading over PACKET_FLOW_FADE_MS) and shoot a comet along the route. Enabling -// it opts the WS connection into resolvedPath data. Geometry is pure (packet-flow.ts); here we own the -// maplibre layers, the dimming, the rAF loop, and the subscription. +// Live mode (MeshMapper LiveViz style): dim every node, then per observed packet shoot an orange dot +// along its real hop path with a fading dashed trail, flashing each node to full opacity as the dot +// crosses it. Enabling it opts the WS connection into resolvedPath data. Geometry is pure +// (packet-flow.ts); here we own the maplibre layers, the dimming, the rAF loop, and the subscription. export function useMapPacketFlow( mapRef: React.RefObject, isReady: boolean, @@ -37,81 +48,134 @@ export function useMapPacketFlow( themeKey: string, resetKey: string, ) { - const litRef = useRef>(new Map()); - const pulsesRef = useRef([]); - const nextIdRef = useRef(0); + const flowsRef = useRef([]); + const hotRef = useRef>(new Map()); // node id -> flash start time const rafRef = useRef(null); - // one animation frame: fade the lit nodes and advance the comets; keep going while either has work + // flash a node to full immediately (cheap GPU feature-state) and register it for decay + const flash = useCallback((id: string | null) => { + if (id == null) return; + hotRef.current.set(id, performance.now()); + try { + mapRef.current?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow: 1 }); + } catch { + // node not currently rendered (e.g. inside a cluster) — nothing to light + } + }, [mapRef]); + + const clearFlows = useCallback((map: MapLibreMap | null) => { + if (rafRef.current != null) { + cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + flowsRef.current = []; + // guard the whole block: on a not-yet-ready or torn-down map, getSource/setFeatureState throw + try { + for (const id of hotRef.current.keys()) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); + (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + } catch { + // map style not ready / already removed + } + hotRef.current.clear(); + }, []); + const startLoop = useCallback(() => { if (rafRef.current != null) return; function frame() { const map = mapRef.current; const now = performance.now(); - for (const [id, n] of litRef.current) { - if (now - n.litAt >= PACKET_FLOW_FADE_MS) litRef.current.delete(id); + // 1) decay node flashes back to dim + for (const [id, t0] of hotRef.current) { + const glow = 1 - (now - t0) / PACKET_FLOW_FLASH_MS; + try { + if (glow <= 0) { + map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); + hotRef.current.delete(id); + } else { + map?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow }); + } + } catch { /* node gone */ } } - const litSrc = map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined; - if (litSrc) litSrc.setData(buildLitFC([...litRef.current.values()], now, PACKET_FLOW_FADE_MS)); - pulsesRef.current = pulsesRef.current.filter((p) => pulseProgress(p, now) <= 1); - const cometSrc = map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined; - if (cometSrc) cometSrc.setData(buildCometFC(pulsesRef.current, now)); + // 2) advance packets -> growing dashed trail + moving dot, flashing nodes as they're crossed + const dots: Feature[] = []; + const lines: Feature[] = []; + for (let i = flowsRef.current.length - 1; i >= 0; i--) { + const p = flowsRef.current[i]!; + const nSeg = p.coords.length - 1; + const t = (now - p.start) / PACKET_FLOW_HOP_MS; + if (p.lastNode < 0) { flash(p.ids[0] ?? null); p.lastNode = 0; } + const node = Math.min(nSeg, Math.floor(t + 1e-6)); + if (node > p.lastNode) { + for (let nn = p.lastNode + 1; nn <= node; nn++) flash(p.ids[nn] ?? null); + p.lastNode = node; + } + const headT = Math.min(t, nSeg); + const fade = t > nSeg ? Math.max(0, 1 - (now - (p.start + nSeg * PACKET_FLOW_HOP_MS)) / PACKET_FLOW_TRAIL_FADE_MS) : 1; + const coords = trailCoords(p.coords, headT); + if (coords.length >= 2) { + lines.push({ type: "Feature", properties: { a: 0.6 * fade }, geometry: { type: "LineString", coordinates: coords } }); + } + if (t <= nSeg) { + dots.push({ type: "Feature", properties: { r: 5, a: 1 }, geometry: { type: "Point", coordinates: posAtHop(p.coords, headT) } }); + } + if (t > nSeg && fade <= 0) flowsRef.current.splice(i, 1); + } - const busy = litRef.current.size > 0 || pulsesRef.current.length > 0; + (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: lines }); + (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: dots }); + + const busy = flowsRef.current.length > 0 || hotRef.current.size > 0; rafRef.current = busy ? requestAnimationFrame(frame) : null; } rafRef.current = requestAnimationFrame(frame); - }, [mapRef]); + }, [mapRef, flash]); - // build both layers: the node-highlight glow and the comet (head + trail). Re-adds after a style switch. + // build the trail + dot layers (re-add after a style switch); the dot is orange with a white stroke + // and a dark halo behind it, the trail a dashed line whose opacity is data-driven useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; - const accent = paletteVar("--palette-primary", "#3B82F6"); - if (!map.getSource(PACKET_FLOW_SOURCE_ID)) { - map.addSource(PACKET_FLOW_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + if (!map.getSource(PACKET_FLOW_TRAIL_SOURCE_ID)) { + map.addSource(PACKET_FLOW_TRAIL_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); + } + if (!map.getLayer(PACKET_FLOW_TRAIL_LAYER_ID)) { + map.addLayer({ + id: PACKET_FLOW_TRAIL_LAYER_ID, + type: "line", + source: PACKET_FLOW_TRAIL_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": PACKET_FLOW_COLOR, "line-width": 2.5, "line-dasharray": [2, 2], "line-opacity": ["get", "a"] }, + } as LineLayerSpecification); + } + if (!map.getSource(PACKET_FLOW_DOT_SOURCE_ID)) { + map.addSource(PACKET_FLOW_DOT_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); } - if (!map.getLayer(PACKET_FLOW_LAYER_ID)) { - // no beforeId: draw over the dimmed markers + if (!map.getLayer(PACKET_FLOW_DOT_HALO_LAYER_ID)) { map.addLayer({ - id: PACKET_FLOW_LAYER_ID, + id: PACKET_FLOW_DOT_HALO_LAYER_ID, type: "circle", - source: PACKET_FLOW_SOURCE_ID, - paint: { - "circle-radius": 9, - "circle-color": accent, - "circle-opacity": ["*", ["get", "opacity"], 0.85], - "circle-blur": 0.35, - "circle-stroke-width": 2, - "circle-stroke-color": accent, - "circle-stroke-opacity": ["get", "opacity"], - }, + source: PACKET_FLOW_DOT_SOURCE_ID, + paint: { "circle-radius": ["+", ["get", "r"], 2.4], "circle-color": "rgba(0,0,0,0.5)", "circle-opacity": ["*", ["get", "a"], 0.5], "circle-blur": 0.5 }, } as CircleLayerSpecification); } - map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-color", accent); - map.setPaintProperty(PACKET_FLOW_LAYER_ID, "circle-stroke-color", accent); - - // comet on top of the glow - if (!map.getSource(PACKET_FLOW_COMET_SOURCE_ID)) { - map.addSource(PACKET_FLOW_COMET_SOURCE_ID, { type: "geojson", data: EMPTY_FC }); - } - if (!map.getLayer(PACKET_FLOW_COMET_LAYER_ID)) { + if (!map.getLayer(PACKET_FLOW_DOT_LAYER_ID)) { map.addLayer({ - id: PACKET_FLOW_COMET_LAYER_ID, + id: PACKET_FLOW_DOT_LAYER_ID, type: "circle", - source: PACKET_FLOW_COMET_SOURCE_ID, + source: PACKET_FLOW_DOT_SOURCE_ID, paint: { - "circle-radius": ["case", ["==", ["get", "head"], 1], 5, 3], - "circle-color": accent, - "circle-opacity": ["get", "opacity"], - "circle-blur": 0.5, + "circle-radius": ["get", "r"], + "circle-color": PACKET_FLOW_COLOR, + "circle-opacity": ["get", "a"], + "circle-stroke-color": "#ffffff", + "circle-stroke-width": ["*", ["get", "a"], 1.1], }, } as CircleLayerSpecification); } - map.setPaintProperty(PACKET_FLOW_COMET_LAYER_ID, "circle-color", accent); }, [mapRef, isReady, themeKey]); // connection-wide resolvePath toggle: on while enabled, off otherwise @@ -120,93 +184,68 @@ export function useMapPacketFlow( return () => wsManager.setResolvePath(false); }, [enabled, wsManager]); - // dim (or restore) the base node + cluster layers. Keyed on themeKey too so it re-applies after - // useMapNodes rebuilds its layers on a theme/style change (that hook runs first, resetting opacity). + // dim the base nodes. The point layer lifts back to full per-node via feature-state glow; clusters + // dim flat (a clustered node can't be individually flashed). Keyed on themeKey so it re-applies + // after useMapNodes rebuilds its layers on a theme/style change. useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; - const iconOpacity = enabled ? LIVE_DIM_OPACITY : 1; - for (const id of [NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID]) { - if (map.getLayer(id)) map.setPaintProperty(id, "icon-opacity", iconOpacity); - } if (map.getLayer(NODES_POINT_LAYER_ID)) { + map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", enabled ? LIVE_ICON_OPACITY : 1); map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY); } if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", enabled ? LIVE_DIM_OPACITY : 1); map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1); } }, [mapRef, isReady, enabled, themeKey]); - // per observed packet: flash its route's nodes and launch a comet along the route + // launch a flow per observed packet; tear the animation down when disabled useEffect(() => { if (!enabled) return; const map = mapRef.current; - const lit = litRef.current; // stable Map for the component's life; used in the cleanup too const unsub = wsManager.onPacketObservation((data) => { const resolved = data.observation?.resolvedPath; - if (!resolved || resolved.length === 0) return; + if (!resolved) return; const nodes = resolvedPathNodes(resolved); - if (nodes.length === 0) return; - const now = performance.now(); - - for (const n of nodes) lit.set(n.id, { lng: n.lng, lat: n.lat, litAt: now }); - - if (nodes.length >= 2) { - const coords = nodes.map((n) => [n.lng, n.lat] as [number, number]); - const { cumLengths, total } = routeMetrics(coords); - if (total > 0) { - pulsesRef.current.push({ - id: nextIdRef.current++, - coords, - cumLengths, - total, - startMs: now, - durationMs: (coords.length - 1) * PACKET_FLOW_SEGMENT_MS, - }); - } - } + if (nodes.length < 2) return; // need at least two located hops to animate a path + while (flowsRef.current.length >= PACKET_FLOW_MAX) flowsRef.current.shift(); + flowsRef.current.push({ + coords: nodes.map((n) => [n.lng, n.lat] as [number, number]), + ids: nodes.map((n) => n.id), + start: performance.now(), + lastNode: -1, + }); startLoop(); }); return () => { unsub(); - lit.clear(); - pulsesRef.current = []; - if (rafRef.current != null) { - cancelAnimationFrame(rafRef.current); - rafRef.current = null; - } - (map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); - (map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); + clearFlows(map); }; - }, [enabled, wsManager, mapRef, startLoop]); + }, [enabled, wsManager, mapRef, startLoop, clearFlows]); - // clear highlights + comets when the region changes (they came from the old dataset) + // clear on region change (paths came from the old dataset) useEffect(() => { - litRef.current.clear(); - pulsesRef.current = []; - const map = mapRef.current; - (map?.getSource(PACKET_FLOW_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); - (map?.getSource(PACKET_FLOW_COMET_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); - }, [resetKey, mapRef]); + clearFlows(mapRef.current); + }, [resetKey, mapRef, clearFlows]); - // remove both layers + sources on unmount (runs before useMapLibre's map.remove()) + // remove layers + sources on unmount (runs before useMapLibre's map.remove()) useEffect(() => { const map = mapRef.current; return () => { - if (rafRef.current != null) cancelAnimationFrame(rafRef.current); - rafRef.current = null; + clearFlows(map); if (!map) return; try { - for (const id of [PACKET_FLOW_LAYER_ID, PACKET_FLOW_COMET_LAYER_ID]) { + for (const id of [PACKET_FLOW_TRAIL_LAYER_ID, PACKET_FLOW_DOT_HALO_LAYER_ID, PACKET_FLOW_DOT_LAYER_ID]) { if (map.getLayer(id)) map.removeLayer(id); } - for (const id of [PACKET_FLOW_SOURCE_ID, PACKET_FLOW_COMET_SOURCE_ID]) { + for (const id of [PACKET_FLOW_TRAIL_SOURCE_ID, PACKET_FLOW_DOT_SOURCE_ID]) { if (map.getSource(id)) map.removeSource(id); } } catch { // map may already be torn down } }; - }, [mapRef]); + }, [mapRef, clearFlows]); } diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index d8e6ea08..05155a7d 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -1,81 +1,43 @@ import { describe, it, expect } from "vitest"; -import { resolvedPathNodes, litOpacity, buildLitFC, routeMetrics, positionAt, buildCometFC } from "../../../src/features/map/packet-flow"; +import { resolvedPathNodes, posAtHop, trailCoords } from "../../../src/features/map/packet-flow"; import type { ResolvedHop } from "../../../src/types/api"; -// a high-confidence hop resolved to one located node function hop(id: string, lng: number, lat: number): ResolvedHop { return { confidence: "high", nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }; } describe("resolvedPathNodes", () => { - it("returns each hop's first located node as { id, lng, lat }, in order", () => { - const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("b", -76, 46)]; - expect(resolvedPathNodes(path)).toEqual([ - { id: "a", lng: -75, lat: 45 }, - { id: "b", lng: -76, lat: 46 }, - ]); - }); - - it("dedupes a node that appears on more than one hop", () => { - const path: ResolvedHop[] = [hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)]; - expect(resolvedPathNodes(path).map((n) => n.id)).toEqual(["a", "b"]); + it("returns each hop's first located node as {id,lng,lat}, deduped, in order", () => { + const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("a", -75, 45), hop("b", -76, 46)]; + expect(resolvedPathNodes(path)).toEqual([{ id: "a", lng: -75, lat: 45 }, { id: "b", lng: -76, lat: 46 }]); }); it("skips hops with no located candidate", () => { - const path: ResolvedHop[] = [{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }]; - expect(resolvedPathNodes(path)).toEqual([]); + expect(resolvedPathNodes([{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }])).toEqual([]); }); }); -describe("litOpacity", () => { - it("is full at the moment lit and eases to zero by fadeMs", () => { - expect(litOpacity(1000, 1000, 4000)).toBe(1); // just lit - expect(litOpacity(1000, 3000, 4000)).toBeCloseTo(0.5); // halfway - expect(litOpacity(1000, 5000, 4000)).toBe(0); // fully faded - expect(litOpacity(1000, 9000, 4000)).toBe(0); // past its life, clamped - }); -}); +describe("posAtHop", () => { + const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; -describe("buildLitFC", () => { - it("emits one point feature per lit node with its current opacity", () => { - const lit = [{ lng: -75, lat: 45, litAt: 1000 }, { lng: -76, lat: 46, litAt: 3000 }]; - const fc = buildLitFC(lit, 3000, 4000); - expect(fc.features).toHaveLength(2); - expect(fc.features[0]!.geometry.coordinates).toEqual([-75, 45]); - expect(fc.features[0]!.properties.opacity).toBeCloseTo(0.5); // lit at 1000, now 3000 - expect(fc.features[1]!.properties.opacity).toBe(1); // just lit + it("returns hop endpoints at integer t and interpolates within a segment", () => { + expect(posAtHop(coords, 0)).toEqual([0, 0]); + expect(posAtHop(coords, 1)).toEqual([10, 0]); + expect(posAtHop(coords, 2)).toEqual([10, 10]); + expect(posAtHop(coords, 0.5)).toEqual([5, 0]); // halfway through hop 0 + expect(posAtHop(coords, 1.5)).toEqual([10, 5]); // halfway through hop 1 }); -}); -describe("routeMetrics + positionAt", () => { - it("measures cumulative length and interpolates along the route", () => { - const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; - const { cumLengths, total } = routeMetrics(coords); - expect(total).toBe(20); - expect(positionAt(coords, cumLengths, total, 0)).toEqual([0, 0]); - expect(positionAt(coords, cumLengths, total, 1)).toEqual([10, 10]); - expect(positionAt(coords, cumLengths, total, 0.75)).toEqual([10, 5]); + it("clamps beyond either end", () => { + expect(posAtHop(coords, -1)).toEqual([0, 0]); + expect(posAtHop(coords, 9)).toEqual([10, 10]); }); }); -describe("buildCometFC", () => { - const coords: [number, number][] = [[0, 0], [10, 0]]; - const { cumLengths, total } = routeMetrics(coords); - const pulse = { id: 1, coords, cumLengths, total, startMs: 0, durationMs: 1000 }; - - it("emits a bright head at the current position plus a dimmer trail behind it", () => { - const fc = buildCometFC([pulse], 500); // halfway - const head = fc.features[0]!; - expect(head.properties.head).toBe(1); - expect(head.geometry.coordinates[0]).toBeCloseTo(5, 5); // midpoint of the segment - expect(fc.features.length).toBeGreaterThan(1); // has a trail - expect(fc.features[1]!.properties.head).toBe(0); - expect(fc.features[1]!.properties.opacity).toBeLessThan(head.properties.opacity); - }); +describe("trailCoords", () => { + const coords: [number, number][] = [[0, 0], [10, 0], [10, 10]]; - it("fades the comet out as it arrives at the end", () => { - const near = buildCometFC([pulse], 990).features[0]!.properties.opacity; // ~arrived - const mid = buildCometFC([pulse], 400).features[0]!.properties.opacity; - expect(near).toBeLessThan(mid); + it("traces every crossed hop plus the current head position", () => { + expect(trailCoords(coords, 1.5)).toEqual([[0, 0], [10, 0], [10, 5]]); }); }); From c41fe359a6e05835a2fa998edadc97c2d23e7359 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 7 Jul 2026 23:07:53 -0400 Subject: [PATCH 009/163] map: fade live-mode clusters more so dense ones stay see-through --- src/features/map/types.ts | 5 ++++- src/features/map/useMapPacketFlow.ts | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/map/types.ts b/src/features/map/types.ts index b4009cac..c96b1849 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -108,7 +108,10 @@ export const PACKET_FLOW_HOP_MS = 480; // ms the dot takes to cross one hop segm export const PACKET_FLOW_FLASH_MS = 900; // a crossed node's flash decays back to dim over this export const PACKET_FLOW_TRAIL_FADE_MS = 1000; // the dashed trail fades once the dot reaches the end export const PACKET_FLOW_MAX = 120; // cap on concurrent packet animations (busy-feed guard) -export const LIVE_DIM_OPACITY = 0.18; // idle node/cluster opacity while Live mode is on +export const LIVE_DIM_OPACITY = 0.18; // idle individual-node opacity while Live mode is on +// Clusters dim further: many overlapping semi-transparent hexagons composite toward opaque, so a +// dense cluster field stops being see-through. A lower per-cluster alpha keeps the stack translucent. +export const LIVE_CLUSTER_DIM_OPACITY = 0.07; export const CLUSTER_RADIUS = 50; // px // Keep clustering alive across the whole reachable zoom range (default max is 22). maplibre drops diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 2c4d3cde..92e5a001 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -15,6 +15,7 @@ import { PACKET_FLOW_TRAIL_FADE_MS, PACKET_FLOW_MAX, LIVE_DIM_OPACITY, + LIVE_CLUSTER_DIM_OPACITY, NODES_SOURCE_ID, NODES_POINT_LAYER_ID, NODES_CLUSTER_LAYER_ID, @@ -195,7 +196,7 @@ export function useMapPacketFlow( map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY); } if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { - map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", enabled ? LIVE_DIM_OPACITY : 1); + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", enabled ? LIVE_CLUSTER_DIM_OPACITY : 1); map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1); } }, [mapRef, isReady, enabled, themeKey]); From 03d7a375a3cf081a54a3ab8196a4f4935c24ba5a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 7 Jul 2026 23:14:39 -0400 Subject: [PATCH 010/163] map: persist clustering + node-type, default neighbor lines to selected --- src/features/map/MapView.tsx | 21 +++++++++++++++------ src/features/map/types.ts | 2 ++ 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index d24a131d..50c32dd0 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -9,7 +9,7 @@ import { PacketFlowButton } from "./PacketFlowButton"; import { useMapNodesData } from "./useMapNodesData"; import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, type NeighborEdgeProps } from "./node-geojson"; import { MapSettingsPanel } from "./MapSettingsPanel"; -import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, type NeighborLinesMode } from "./types"; +import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, type NeighborLinesMode } from "./types"; import type { FeatureCollection, LineString } from "geojson"; import { EmptyState } from "../../components/EmptyState"; import { LoadingPill } from "../../components/LoadingPill"; @@ -50,12 +50,21 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp localStorage.setItem(MAP_STYLE_STORAGE_KEY, lastGoodStyleId); }, []); - const [typeFilter, setTypeFilter] = useState(""); // "" = All - const [clustered, setClustered] = useState(true); + const [typeFilter, setTypeFilter] = useState(() => localStorage.getItem(MAP_NODE_TYPE_STORAGE_KEY) ?? ""); // "" = All + const handleTypeChange = useCallback((t: string) => { + setTypeFilter(t); + localStorage.setItem(MAP_NODE_TYPE_STORAGE_KEY, t); + }, []); + + const [clustered, setClustered] = useState(() => localStorage.getItem(MAP_CLUSTER_STORAGE_KEY) !== "off"); + const handleClusteredChange = useCallback((c: boolean) => { + setClustered(c); + localStorage.setItem(MAP_CLUSTER_STORAGE_KEY, c ? "on" : "off"); + }, []); const [neighborLines, setNeighborLines] = useState(() => { const stored = localStorage.getItem(MAP_NEIGHBOR_LINES_STORAGE_KEY); - return stored === "on" || stored === "selected" ? stored : "off"; + return stored === "on" || stored === "selected" || stored === "off" ? stored : "selected"; }); const handleNeighborLinesChange = useCallback((mode: NeighborLinesMode) => { setNeighborLines(mode); @@ -135,9 +144,9 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp styleId={styleId} onStyleChange={handleStyleChange} typeFilter={typeFilter} - onTypeChange={setTypeFilter} + onTypeChange={handleTypeChange} clustered={clustered} - onClusteredChange={setClustered} + onClusteredChange={handleClusteredChange} neighborLines={neighborLines} onNeighborLinesChange={handleNeighborLinesChange} /> diff --git a/src/features/map/types.ts b/src/features/map/types.ts index c96b1849..0237143d 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -18,6 +18,8 @@ export const DEFAULT_STYLE_ID = "dark"; // beacon-* matches the codebase convention (beacon-theme, beacon-region, beacon-analyzer-open) export const MAP_STYLE_STORAGE_KEY = "beacon-map-style"; +export const MAP_CLUSTER_STORAGE_KEY = "beacon-map-clustering"; +export const MAP_NODE_TYPE_STORAGE_KEY = "beacon-map-node-type"; // Always returns an option: falls back to the first entry, which also covers a stale/invalid id // restored from localStorage. From dc64382837035811573b27cd7f7c6ff697b43ead Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 7 Jul 2026 23:24:29 -0400 Subject: [PATCH 011/163] map: fade live-mode node markers more so overlaps stay see-through --- src/features/map/types.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/features/map/types.ts b/src/features/map/types.ts index 0237143d..2cda43dd 100644 --- a/src/features/map/types.ts +++ b/src/features/map/types.ts @@ -110,7 +110,10 @@ export const PACKET_FLOW_HOP_MS = 480; // ms the dot takes to cross one hop segm export const PACKET_FLOW_FLASH_MS = 900; // a crossed node's flash decays back to dim over this export const PACKET_FLOW_TRAIL_FADE_MS = 1000; // the dashed trail fades once the dot reaches the end export const PACKET_FLOW_MAX = 120; // cap on concurrent packet animations (busy-feed guard) -export const LIVE_DIM_OPACITY = 0.18; // idle individual-node opacity while Live mode is on +// Idle individual-node opacity while Live is on. Kept low because with clustering off, co-located +// markers overlap and their alphas composite toward bright; a crossed node still pops to full via +// the max(dim, feature-state glow) expression. +export const LIVE_DIM_OPACITY = 0.08; // Clusters dim further: many overlapping semi-transparent hexagons composite toward opaque, so a // dense cluster field stops being see-through. A lower per-cluster alpha keeps the stack translucent. export const LIVE_CLUSTER_DIM_OPACITY = 0.07; From 8294312ab2477064d608f3bcdd5c2e4234cbf9f3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 7 Jul 2026 23:50:21 -0400 Subject: [PATCH 012/163] map: fade the live-mode node flash in sync with its packet trail --- src/features/map/useMapPacketFlow.ts | 62 ++++++++++++---------------- 1 file changed, 26 insertions(+), 36 deletions(-) diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 92e5a001..498babfe 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -11,7 +11,6 @@ import { PACKET_FLOW_DOT_LAYER_ID, PACKET_FLOW_COLOR, PACKET_FLOW_HOP_MS, - PACKET_FLOW_FLASH_MS, PACKET_FLOW_TRAIL_FADE_MS, PACKET_FLOW_MAX, LIVE_DIM_OPACITY, @@ -50,20 +49,9 @@ export function useMapPacketFlow( resetKey: string, ) { const flowsRef = useRef([]); - const hotRef = useRef>(new Map()); // node id -> flash start time + const litRef = useRef>(new Set()); // node ids currently lit (feature-state glow set) const rafRef = useRef(null); - // flash a node to full immediately (cheap GPU feature-state) and register it for decay - const flash = useCallback((id: string | null) => { - if (id == null) return; - hotRef.current.set(id, performance.now()); - try { - mapRef.current?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow: 1 }); - } catch { - // node not currently rendered (e.g. inside a cluster) — nothing to light - } - }, [mapRef]); - const clearFlows = useCallback((map: MapLibreMap | null) => { if (rafRef.current != null) { cancelAnimationFrame(rafRef.current); @@ -72,13 +60,13 @@ export function useMapPacketFlow( flowsRef.current = []; // guard the whole block: on a not-yet-ready or torn-down map, getSource/setFeatureState throw try { - for (const id of hotRef.current.keys()) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); + for (const id of litRef.current) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData(EMPTY_FC); } catch { // map style not ready / already removed } - hotRef.current.clear(); + litRef.current.clear(); }, []); const startLoop = useCallback(() => { @@ -87,34 +75,20 @@ export function useMapPacketFlow( const map = mapRef.current; const now = performance.now(); - // 1) decay node flashes back to dim - for (const [id, t0] of hotRef.current) { - const glow = 1 - (now - t0) / PACKET_FLOW_FLASH_MS; - try { - if (glow <= 0) { - map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); - hotRef.current.delete(id); - } else { - map?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow }); - } - } catch { /* node gone */ } - } - - // 2) advance packets -> growing dashed trail + moving dot, flashing nodes as they're crossed const dots: Feature[] = []; const lines: Feature[] = []; + const glowByNode = new Map(); // node id -> glow this frame (max across packets) + for (let i = flowsRef.current.length - 1; i >= 0; i--) { const p = flowsRef.current[i]!; const nSeg = p.coords.length - 1; const t = (now - p.start) / PACKET_FLOW_HOP_MS; - if (p.lastNode < 0) { flash(p.ids[0] ?? null); p.lastNode = 0; } const node = Math.min(nSeg, Math.floor(t + 1e-6)); - if (node > p.lastNode) { - for (let nn = p.lastNode + 1; nn <= node; nn++) flash(p.ids[nn] ?? null); - p.lastNode = node; - } + if (node > p.lastNode) p.lastNode = node; const headT = Math.min(t, nSeg); + // full while the dot is travelling, then eases out with the trail after it reaches the end const fade = t > nSeg ? Math.max(0, 1 - (now - (p.start + nSeg * PACKET_FLOW_HOP_MS)) / PACKET_FLOW_TRAIL_FADE_MS) : 1; + const coords = trailCoords(p.coords, headT); if (coords.length >= 2) { lines.push({ type: "Feature", properties: { a: 0.6 * fade }, geometry: { type: "LineString", coordinates: coords } }); @@ -122,17 +96,33 @@ export function useMapPacketFlow( if (t <= nSeg) { dots.push({ type: "Feature", properties: { r: 5, a: 1 }, geometry: { type: "Point", coordinates: posAtHop(p.coords, headT) } }); } + // light every node the dot has reached; they hold at full while it travels, then fade with the trail + if (fade > 0) { + for (let k = 0; k <= p.lastNode; k++) { + const id = p.ids[k]; + if (id != null) glowByNode.set(id, Math.max(glowByNode.get(id) ?? 0, fade)); + } + } if (t > nSeg && fade <= 0) flowsRef.current.splice(i, 1); } + // apply node glows via feature-state; drop nodes that are no longer lit by any packet + try { + for (const [id, g] of glowByNode) map?.setFeatureState({ source: NODES_SOURCE_ID, id }, { glow: g }); + for (const id of litRef.current) { + if (!glowByNode.has(id)) map?.removeFeatureState({ source: NODES_SOURCE_ID, id }, "glow"); + } + } catch { /* node gone */ } + litRef.current = new Set(glowByNode.keys()); + (map?.getSource(PACKET_FLOW_TRAIL_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: lines }); (map?.getSource(PACKET_FLOW_DOT_SOURCE_ID) as GeoJSONSource | undefined)?.setData({ type: "FeatureCollection", features: dots }); - const busy = flowsRef.current.length > 0 || hotRef.current.size > 0; + const busy = flowsRef.current.length > 0 || litRef.current.size > 0; rafRef.current = busy ? requestAnimationFrame(frame) : null; } rafRef.current = requestAnimationFrame(frame); - }, [mapRef, flash]); + }, [mapRef]); // build the trail + dot layers (re-add after a style switch); the dot is orange with a white stroke // and a dark halo behind it, the trail a dashed line whose opacity is data-driven From 26e83e08845e3445b827f9e9a52a839fbb290d88 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 7 Jul 2026 23:50:21 -0400 Subject: [PATCH 013/163] nodes: let the mobile detail panel minimize without deselecting --- src/components/DetailPanel.tsx | 36 ++++++++++++++++++--- src/features/nodes/NodeDetailPanel.tsx | 1 + tests/components/DetailPanel.test.tsx | 43 ++++++++++++++++++++++++++ 3 files changed, 75 insertions(+), 5 deletions(-) create mode 100644 tests/components/DetailPanel.test.tsx diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index ecb8bafe..427dbfab 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { useState, type ReactNode } from "react"; import { CloseButton } from "./CloseButton"; // shared scaffolding for the right-hand entity detail panels (observers, nodes, …) @@ -18,9 +18,28 @@ export function Field({ label, value }: { label: string; value: ReactNode }) { ); } +// Minimize/expand toggle for the mobile overlay only; at md+ the panel is a sidebar so it's hidden. +function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle: () => void }) { + return ( + + ); +} + interface DetailPanelProps { title: string; onClose: () => void; + // mobile-only: adds a minimize toggle that collapses to a bottom bar without deselecting, so the + // map underneath (and its neighbor lines) stays visible. No effect at md+ (the panel is a sidebar). + collapsible?: boolean; isLoading?: boolean; notFound?: boolean; notFoundIcon?: ReactNode; @@ -28,15 +47,22 @@ interface DetailPanelProps { children: ReactNode; } -export function DetailPanel({ title, onClose, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", children }: DetailPanelProps) { +export function DetailPanel({ title, onClose, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", children }: DetailPanelProps) { + const [collapsed, setCollapsed] = useState(false); + // collapse only touches the mobile overlay: shrink to a bottom bar and hide the body. The md:* + // classes below always win at desktop width, so a lingering collapsed state never hides the sidebar. + const minimized = Boolean(collapsible && collapsed); return ( -
+
{title} - +
+ {collapsible && setCollapsed((v) => !v)} />} + +
-
+
{isLoading ? (
Loading... diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 91673c16..7dba5881 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -97,6 +97,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o void } = {}) { + const onClose = props.onClose ?? vi.fn(); + render( + +

neighbor details

+
, + ); + return { onClose }; +} + +describe("DetailPanel", () => { + it("renders no minimize control unless collapsible", () => { + renderPanel(); + expect(screen.queryByRole("button", { name: /minimize/i })).toBeNull(); + }); + + it("minimizes without closing, keeping the entity selected", () => { + const { onClose } = renderPanel({ collapsible: true }); + fireEvent.click(screen.getByRole("button", { name: "Minimize detail panel" })); + expect(onClose).not.toHaveBeenCalled(); + // still selected: the content stays mounted (just visually collapsed on mobile) + expect(screen.getByTestId("body")).toBeTruthy(); + // the control now offers to expand again + expect(screen.getByRole("button", { name: "Expand detail panel" })).toBeTruthy(); + }); + + it("toggles back to expanded", () => { + renderPanel({ collapsible: true }); + fireEvent.click(screen.getByRole("button", { name: "Minimize detail panel" })); + fireEvent.click(screen.getByRole("button", { name: "Expand detail panel" })); + expect(screen.getByRole("button", { name: "Minimize detail panel" })).toBeTruthy(); + }); + + it("still closes via the close button", () => { + const { onClose } = renderPanel({ collapsible: true }); + fireEvent.click(screen.getByRole("button", { name: "Close detail panel" })); + expect(onClose).toHaveBeenCalledTimes(1); + }); +}); From 5462c0866248e65d285ae16fc4442d398c4aaa1e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 8 Jul 2026 08:41:00 -0400 Subject: [PATCH 014/163] map: dim other nodes to spotlight a selected node and its neighbours --- src/features/map/MapView.tsx | 11 +++++-- src/features/map/node-geojson.ts | 23 +++++++++++++++ src/features/map/useMapNodes.ts | 39 +++++++++++++++++++++++-- src/features/map/useMapPacketFlow.ts | 29 ++---------------- tests/features/map/node-geojson.test.ts | 38 +++++++++++++++++++++++- 5 files changed, 109 insertions(+), 31 deletions(-) diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 50c32dd0..058d4320 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -7,7 +7,7 @@ import { useMapNeighbors } from "./useMapNeighbors"; import { useMapPacketFlow } from "./useMapPacketFlow"; import { PacketFlowButton } from "./PacketFlowButton"; import { useMapNodesData } from "./useMapNodesData"; -import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, type NeighborEdgeProps } from "./node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson"; import { MapSettingsPanel } from "./MapSettingsPanel"; import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, type NeighborLinesMode } from "./types"; import type { FeatureCollection, LineString } from "geojson"; @@ -117,6 +117,13 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp [nodes, neighborLines, selectedNodeId], ); + // With neighbors shown and a node selected, fade every other node (like live mode) to spotlight + // the selection and its neighbors. null when there's nothing to focus, so the map stays full-bright. + const focusIds = useMemo( + () => (neighborLines === "off" ? null : neighborFocusIds(nodes, selectedNodeId)), + [nodes, neighborLines, selectedNodeId], + ); + // IATA coords to frame: the selection's airports, or every airport for "All". Regions carry no // bounds from the API, so their member IATAs stand in for the extent. See CLAUDE.md (map framing). const fitPoints = useMemo<[number, number][] | null>(() => { @@ -130,7 +137,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome - useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, `${regionKey}:${typeFilter}`); + useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index a0a69a78..6929b16a 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -73,6 +73,29 @@ export function buildNeighborEdges( return { type: "FeatureCollection", features }; } +// The located nodes to keep lit when a node is selected: the selection plus its neighbors (links are +// undirected — a node listing the selection counts). Returns null when there's nothing to focus on: +// no selection, the selected node isn't on the map, or it has no located neighbors. Mirrors the +// undirected logic in buildNeighborEdges so the bright set matches the drawn edges. +export function neighborFocusIds(nodes: NodeSummary[], selectedId: string | null): string[] | null { + if (!selectedId) return null; + const located = new Map(); + for (const n of nodes) { + if (n.lat != null && n.lng != null) located.set(n.id, n); + } + const selected = located.get(selectedId); + if (!selected) return null; + + const focus = new Set([selectedId]); + for (const otherId of selected.neighborIds ?? []) { + if (otherId !== selectedId && located.has(otherId)) focus.add(otherId); + } + for (const n of located.values()) { + if (n.id !== selectedId && n.neighborIds?.includes(selectedId)) focus.add(n.id); + } + return focus.size > 1 ? [...focus] : null; +} + // Filter to a single device type ("" = All). Filtering the data (not a layer filter) lets the // clustered source re-count only the visible type. export function filterByNodeType( diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index 951479c5..c4a78071 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -21,6 +21,8 @@ import { NODES_SOURCE_MAXZOOM, SPIDERFY_MIN_ZOOM, NODE_LABEL_MIN_ZOOM, + LIVE_DIM_OPACITY, + LIVE_CLUSTER_DIM_OPACITY, NODE_TYPE_NAMES, NODE_ICON_UNKNOWN, nodeIconId, @@ -81,6 +83,11 @@ const ICON_IMAGE: ExpressionSpecification = [ NODE_ICON_UNKNOWN, ] as unknown as ExpressionSpecification; +// Node labels fade in only past NODE_LABEL_MIN_ZOOM. +const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; +// Live mode: dim to the idle floor, but lift a currently-flashing node to full (feature-state glow 0..1). +const LIVE_ICON_OPACITY: ExpressionSpecification = ["max", LIVE_DIM_OPACITY, ["coalesce", ["feature-state", "glow"], 0]]; + const SPIDER_LEAVES_LAYOUT: SymbolLayerSpecification["layout"] = { "icon-image": ICON_IMAGE, "icon-size": 1, @@ -98,6 +105,10 @@ export function useMapNodes( clustered: boolean, onSelectNode: (id: string) => void, selectedNodeId: string | null, + // live packet-flow on: fade every node (a crossed one lifts via feature-state glow) + live: boolean, + // selection focus: keep only these node ids lit and fade the rest; null = off + focusIds: string[] | null, // identity of the dataset (region + type filter); an open spiderfy fan closes when it changes, // since its leaves were drawn from the previous dataset resetKey = "", @@ -210,8 +221,7 @@ export function useMapNodes( "text-color": textColor, "text-halo-color": halo, "text-halo-width": 1.3, - // labels fade in only at high zoom - "text-opacity": ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1], + "text-opacity": LABEL_OPACITY, // labels fade in only at high zoom }, } as SymbolLayerSpecification); } @@ -313,6 +323,31 @@ export function useMapNodes( syncLeafSelectionRing(map, selectedNodeId); }, [mapRef, isReady, selectedNodeId]); + // Base-layer opacity for the two "fade all but a subset" dim modes. Single owner of icon/text + // opacity so live mode and selection focus never fight over the paint property; re-applies after a + // style/theme/clustering rebuild via the deps. Live wins over focus. The live-mode packet glow + // rides feature-state (set by useMapPacketFlow's loop), so it needs no re-run here. + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + // lit for the focus set, dimmed otherwise + const focusCase = (lit: ExpressionSpecification | number, dim: ExpressionSpecification | number) => + ["case", ["in", ["get", "id"], ["literal", focusIds ?? []]], lit, dim] as ExpressionSpecification; + + const iconOpacity: ExpressionSpecification | number = live ? LIVE_ICON_OPACITY : focusIds ? focusCase(1, LIVE_DIM_OPACITY) : 1; + const labelOpacity: ExpressionSpecification | number = live ? 0 : focusIds ? focusCase(LABEL_OPACITY, 0) : LABEL_OPACITY; + const dimActive = live || Boolean(focusIds); + if (map.getLayer(NODES_POINT_LAYER_ID)) { + map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", iconOpacity); + map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", labelOpacity); + } + // a cluster can't tell which nodes it holds, so both modes just dim it flat (matches live mode) + if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", dimActive ? LIVE_CLUSTER_DIM_OPACITY : 1); + map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", dimActive ? 0 : 1); + } + }, [mapRef, isReady, live, focusIds, clustered, themeKey]); + // Push new node data into the source as it arrives; the source re-clusters automatically. useEffect(() => { const map = mapRef.current; diff --git a/src/features/map/useMapPacketFlow.ts b/src/features/map/useMapPacketFlow.ts index 498babfe..b4e815f7 100644 --- a/src/features/map/useMapPacketFlow.ts +++ b/src/features/map/useMapPacketFlow.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef } from "react"; -import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl"; +import type { Map as MapLibreMap, GeoJSONSource, CircleLayerSpecification, LineLayerSpecification } from "maplibre-gl"; import type { Feature, FeatureCollection, Point, LineString } from "geojson"; import type { WsManager } from "../../api/ws-manager"; import { resolvedPathNodes, posAtHop, trailCoords } from "./packet-flow"; @@ -13,21 +13,11 @@ import { PACKET_FLOW_HOP_MS, PACKET_FLOW_TRAIL_FADE_MS, PACKET_FLOW_MAX, - LIVE_DIM_OPACITY, - LIVE_CLUSTER_DIM_OPACITY, NODES_SOURCE_ID, - NODES_POINT_LAYER_ID, - NODES_CLUSTER_LAYER_ID, - NODE_LABEL_MIN_ZOOM, } from "./types"; const EMPTY_FC: FeatureCollection = { type: "FeatureCollection", features: [] }; -// node labels normally fade in past NODE_LABEL_MIN_ZOOM — restored when Live turns off -const LABEL_OPACITY: ExpressionSpecification = ["step", ["zoom"], 0, NODE_LABEL_MIN_ZOOM, 1]; -// dimmed to the idle baseline, but lifted to full for a node currently flashing (feature-state glow 0..1) -const LIVE_ICON_OPACITY: ExpressionSpecification = ["max", LIVE_DIM_OPACITY, ["coalesce", ["feature-state", "glow"], 0]]; - // one packet riding its hop path once interface Flow { coords: [number, number][]; @@ -175,21 +165,8 @@ export function useMapPacketFlow( return () => wsManager.setResolvePath(false); }, [enabled, wsManager]); - // dim the base nodes. The point layer lifts back to full per-node via feature-state glow; clusters - // dim flat (a clustered node can't be individually flashed). Keyed on themeKey so it re-applies - // after useMapNodes rebuilds its layers on a theme/style change. - useEffect(() => { - const map = mapRef.current; - if (!map || !isReady) return; - if (map.getLayer(NODES_POINT_LAYER_ID)) { - map.setPaintProperty(NODES_POINT_LAYER_ID, "icon-opacity", enabled ? LIVE_ICON_OPACITY : 1); - map.setPaintProperty(NODES_POINT_LAYER_ID, "text-opacity", enabled ? 0 : LABEL_OPACITY); - } - if (map.getLayer(NODES_CLUSTER_LAYER_ID)) { - map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "icon-opacity", enabled ? LIVE_CLUSTER_DIM_OPACITY : 1); - map.setPaintProperty(NODES_CLUSTER_LAYER_ID, "text-opacity", enabled ? 0 : 1); - } - }, [mapRef, isReady, enabled, themeKey]); + // Base-node dimming (fade all, lift the flashing node) is owned by useMapNodes so live mode and + // selection focus share one opacity owner; here we only feed it the per-node glow feature-state. // launch a flow per observed packet; tear the animation down when disabled useEffect(() => { diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 27eee626..10e356f1 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges } from "../../../src/features/map/node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds } from "../../../src/features/map/node-geojson"; import type { NodeSummary } from "../../../src/features/nodes/types"; function node(overrides: Partial): NodeSummary { @@ -116,6 +116,42 @@ describe("buildNeighborEdges", () => { }); }); +describe("neighborFocusIds", () => { + const a = node({ id: "a", lat: 45, lng: -75, neighborIds: ["b", "c"] }); + const b = node({ id: "b", lat: 46, lng: -76, neighborIds: ["a"] }); + const c = node({ id: "c", lat: 47, lng: -77, neighborIds: ["a"] }); + + const sorted = (ids: string[] | null) => (ids ? [...ids].sort() : ids); + + it("returns null when nothing is selected", () => { + expect(neighborFocusIds([a, b, c], null)).toBeNull(); + }); + + it("includes the selected node and its located neighbors", () => { + expect(sorted(neighborFocusIds([a, b, c], "a"))).toEqual(["a", "b", "c"]); + }); + + it("treats neighbor links as undirected (a node listing the selection counts)", () => { + // b lists a; c does not list b, so only a<->b makes c irrelevant to b's focus set + expect(sorted(neighborFocusIds([a, b, c], "b"))).toEqual(["a", "b"]); + }); + + it("skips neighbor ids that are absent or unlocated", () => { + const noCoord = node({ id: "c", lat: null, lng: null, neighborIds: ["a"] }); + expect(sorted(neighborFocusIds([a, b, noCoord], "a"))).toEqual(["a", "b"]); + }); + + it("returns null when the selected node is unlocated (no marker to keep lit)", () => { + const unlocated = node({ id: "a", lat: null, lng: null, neighborIds: ["b"] }); + expect(neighborFocusIds([unlocated, b], "a")).toBeNull(); + }); + + it("returns null when the selected node has no located neighbors", () => { + const lonely = node({ id: "x", lat: 45, lng: -75, neighborIds: ["ghost"] }); + expect(neighborFocusIds([lonely, b], "x")).toBeNull(); + }); +}); + describe("filterByNodeType", () => { const fc = nodesToFeatureCollection([ node({ id: "r1", nodeTypeName: "repeater" }), From 92806faaa426c34631f16e72a279cfa68f7c9087 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 8 Jul 2026 09:33:57 -0400 Subject: [PATCH 015/163] map: colour a selected node's neighbour lines by obs count and freshness --- src/features/map/MapSettingsPanel.tsx | 21 ++++++++++ src/features/map/MapView.tsx | 30 ++++++++++---- src/features/map/node-geojson.ts | 44 +++++++++++++++++++- src/features/map/useMapNeighbors.ts | 33 ++++++++++++--- tests/features/map/node-geojson.test.ts | 54 ++++++++++++++++++++++++- 5 files changed, 166 insertions(+), 16 deletions(-) diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index 32834597..b9313d7c 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -19,6 +19,26 @@ const NEIGHBOR_OPTIONS = [ { value: "off", label: "Off" }, ]; +// Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors +// (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme. +function NeighborLegend() { + return ( +
+
Observations
+
+
+ 1 + 20 + 150+ +
+
fainter = heard longer ago
+
+ ); +} + interface MapSettingsPanelProps { styleId: string; onStyleChange: (id: string) => void; @@ -108,6 +128,7 @@ export function MapSettingsPanel({ onChange={(v) => onNeighborLinesChange(v as NeighborLinesMode)} className="w-full" /> + {neighborLines === "selected" && }
)} diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 058d4320..2160b5bd 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -7,7 +7,7 @@ import { useMapNeighbors } from "./useMapNeighbors"; import { useMapPacketFlow } from "./useMapPacketFlow"; import { PacketFlowButton } from "./PacketFlowButton"; import { useMapNodesData } from "./useMapNodesData"; -import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, buildFocusedNeighborEdges, neighborFocusIds, type NeighborEdgeProps } from "./node-geojson"; import { MapSettingsPanel } from "./MapSettingsPanel"; import { MAP_STYLE_STORAGE_KEY, DEFAULT_STYLE_ID, resolveMapStyle, MAP_NEIGHBOR_LINES_STORAGE_KEY, MAP_CLUSTER_STORAGE_KEY, MAP_NODE_TYPE_STORAGE_KEY, type NeighborLinesMode } from "./types"; import type { FeatureCollection, LineString } from "geojson"; @@ -16,7 +16,7 @@ import { LoadingPill } from "../../components/LoadingPill"; import { useRegion } from "../../hooks/useRegion"; import { useTheme } from "../../hooks/useTheme"; import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers"; -import { getIatas } from "../../api/client"; +import { getIatas, getNodeNeighbors } from "../../api/client"; import { upsertNodePages } from "../nodes/node-updates"; import type { WsManager } from "../../api/ws-manager"; import type { NodeSummary } from "../nodes/types"; @@ -110,12 +110,26 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const baseFc = useMemo(() => nodesToFeatureCollection(nodes), [nodes]); const geojson = useMemo(() => filterByNodeType(baseFc, typeFilter), [baseFc, typeFilter]); - // Neighbor edges are a pure client-side render over already-loaded nodes (neighborIds ship with - // every map page), so toggling On/Selected/Off never refetches. "off" short-circuits to no edges. - const neighborEdges = useMemo( - () => (neighborLines === "off" ? EMPTY_EDGES : buildNeighborEdges(nodes, neighborLines, selectedNodeId)), - [nodes, neighborLines, selectedNodeId], - ); + // Selected mode colours the one node's edges by observation count + freshness, which only the node + // detail endpoint carries (the list's neighborIds are bare uuids). Shares the panel's query cache + // (same key), so selecting a node — which opens the panel — usually has this already warm. + const { data: focusNeighbors } = useQuery({ + queryKey: ["node-neighbors", selectedNodeId], + queryFn: () => getNodeNeighbors(selectedNodeId!), + enabled: neighborLines === "selected" && !!selectedNodeId, + staleTime: 30_000, + }); + + // "on" is a pure client-side render over already-loaded nodes (neighborIds ship with every map + // page) so it never refetches; "selected" colours the detail edges; "off" short-circuits to none. + const neighborEdges = useMemo(() => { + if (neighborLines === "off") return EMPTY_EDGES; + if (neighborLines === "selected") { + if (!selectedNodeId) return EMPTY_EDGES; + return buildFocusedNeighborEdges(nodes.find((n) => n.id === selectedNodeId), focusNeighbors ?? []); + } + return buildNeighborEdges(nodes, "on", selectedNodeId); + }, [nodes, neighborLines, selectedNodeId, focusNeighbors]); // With neighbors shown and a node selected, fade every other node (like live mode) to spotlight // the selection and its neighbors. null when there's nothing to focus, so the map stays full-bright. diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index 6929b16a..e6452d66 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -1,5 +1,5 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; -import type { NodeSummary } from "../nodes/types"; +import type { NodeSummary, NodeNeighbor } from "../nodes/types"; // Build the maplibre GeoJSON source from the nodes API response. Properties stay primitive because // clustering serializes them, and there's no maplibre import, so this stays unit-testable. @@ -35,6 +35,11 @@ export function nodesToFeatureCollection( export interface NeighborEdgeProps { selected: boolean; // incident to the currently selected node — styled brighter + // Present only on the selected node's edges (buildFocusedNeighborEdges): total observations of the + // link and its age in days. Drive the obs→color gradient and the freshness fade; absent edges (the + // ambient "on" mesh) render uniform. + obs?: number; + ageDays?: number; } // LineString edges between located nodes and their neighbors (from each node's neighborIds). Each @@ -73,6 +78,43 @@ export function buildNeighborEdges( return { type: "FeatureCollection", features }; } +// The selected node's edges, coloured by observation count and freshness. Data comes from the node +// detail endpoint (GET /nodes/{id}/neighbors), which unlike the list's bare neighborIds carries +// observationCount + lastSeen + coords. That endpoint returns one row per (neighbor, iata), so rows +// are folded per neighbor: obs summed, lastSeen taken at its freshest. `now` defaults to the current +// time; tests pass it explicitly so the age stays deterministic. +export function buildFocusedNeighborEdges( + selected: Pick | null | undefined, + neighbors: NodeNeighbor[], + now: number = Date.now(), +): FeatureCollection { + const empty: FeatureCollection = { type: "FeatureCollection", features: [] }; + if (!selected || selected.lat == null || selected.lng == null) return empty; + const from: [number, number] = [selected.lng, selected.lat]; + + const byId = new Map(); + for (const nb of neighbors) { + if (nb.id === selected.id || nb.lat == null || nb.lng == null) continue; + const prev = byId.get(nb.id); + if (prev) { + prev.obs += nb.observationCount; + prev.lastSeen = Math.max(prev.lastSeen, nb.lastSeen); + } else { + byId.set(nb.id, { lng: nb.lng, lat: nb.lat, obs: nb.observationCount, lastSeen: nb.lastSeen }); + } + } + + const features: Feature[] = []; + for (const n of byId.values()) { + features.push({ + type: "Feature", + geometry: { type: "LineString", coordinates: [from, [n.lng, n.lat]] }, + properties: { selected: true, obs: n.obs, ageDays: Math.max(0, (now - n.lastSeen) / 86400000) }, + }); + } + return { type: "FeatureCollection", features }; +} + // The located nodes to keep lit when a node is selected: the selection plus its neighbors (links are // undirected — a node listing the selection counts). Returns null when there's nothing to focus on: // no selection, the selected node isn't on the map, or it has no located neighbors. Mirrors the diff --git a/src/features/map/useMapNeighbors.ts b/src/features/map/useMapNeighbors.ts index 0d449639..a2163cee 100644 --- a/src/features/map/useMapNeighbors.ts +++ b/src/features/map/useMapNeighbors.ts @@ -1,5 +1,5 @@ import { useEffect, useRef } from "react"; -import type { Map as MapLibreMap, GeoJSONSource, LineLayerSpecification } from "maplibre-gl"; +import type { Map as MapLibreMap, GeoJSONSource, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl"; import type { FeatureCollection, LineString } from "geojson"; import type { NeighborEdgeProps } from "./node-geojson"; import { NEIGHBORS_SOURCE_ID, NEIGHBORS_LINE_LAYER_ID, NODES_CLUSTER_LAYER_ID } from "./types"; @@ -10,6 +10,24 @@ function paletteVar(name: string, fallback: string): string { return getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; } +// Opacity: the selected node's coloured edges (they carry `obs`) fade with age — solid when fresh, +// faint by ~4 weeks (matches the 30-day retention). Ambient "on" edges keep the flat selected/dim split. +const NEIGHBOR_OPACITY = [ + "case", ["has", "obs"], + ["interpolate", ["linear"], ["get", "ageDays"], 0, 0.9, 28, 0.35], + ["case", ["get", "selected"], 0.9, 0.3], +] as ExpressionSpecification; + +// Colour by observation count on a log axis (counts are heavily right-skewed): ~1 red, ~20 yellow, +// ~150+ green, clamped past the ends. Edges without a count (the ambient mesh) fall back to primary. +function neighborLineColor(danger: string, warn: string, green: string, primary: string): ExpressionSpecification { + return [ + "case", ["has", "obs"], + ["interpolate", ["linear"], ["log10", ["max", 1, ["get", "obs"]]], 0, danger, 1.3, warn, 2.18, green], + primary, + ] as ExpressionSpecification; +} + // Draws neighbor edges as a line layer beneath the node markers. Like useMapNodes, the source and // layer re-add themselves after a style switch, and edge data flows through a separate setData // effect so toggling or changing the selection never rebuilds the layer. @@ -29,7 +47,12 @@ export function useMapNeighbors( const map = mapRef.current; if (!map || !isReady) return; - const primary = paletteVar("--palette-primary", "#3B82F6"); + const lineColor = neighborLineColor( + paletteVar("--palette-danger", "#EF4444"), + paletteVar("--palette-warn", "#EAB308"), + paletteVar("--palette-green", "#22C55E"), + paletteVar("--palette-primary", "#3B82F6"), + ); if (!map.getSource(NEIGHBORS_SOURCE_ID)) { map.addSource(NEIGHBORS_SOURCE_ID, { type: "geojson", data: edgesRef.current }); @@ -42,17 +65,17 @@ export function useMapNeighbors( source: NEIGHBORS_SOURCE_ID, layout: { "line-cap": "round", "line-join": "round" }, paint: { - "line-color": primary, + "line-color": lineColor, // edges touching the selected node read stronger than the ambient mesh "line-width": ["case", ["get", "selected"], 2, 1], - "line-opacity": ["case", ["get", "selected"], 0.9, 0.3], + "line-opacity": NEIGHBOR_OPACITY, }, } as LineLayerSpecification, // beneath the node markers; guard the beforeId in case the nodes layer isn't added yet map.getLayer(NODES_CLUSTER_LAYER_ID) ? NODES_CLUSTER_LAYER_ID : undefined, ); } - map.setPaintProperty(NEIGHBORS_LINE_LAYER_ID, "line-color", primary); + map.setPaintProperty(NEIGHBORS_LINE_LAYER_ID, "line-color", lineColor); (map.getSource(NEIGHBORS_SOURCE_ID) as GeoJSONSource).setData(edgesRef.current); }, [mapRef, isReady, themeKey]); diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 10e356f1..79a7de4a 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds } from "../../../src/features/map/node-geojson"; -import type { NodeSummary } from "../../../src/features/nodes/types"; +import { nodesToFeatureCollection, filterByNodeType, buildNeighborEdges, neighborFocusIds, buildFocusedNeighborEdges } from "../../../src/features/map/node-geojson"; +import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function node(overrides: Partial): NodeSummary { return { @@ -152,6 +152,56 @@ describe("neighborFocusIds", () => { }); }); +describe("buildFocusedNeighborEdges", () => { + const DAY = 86400000; + const NOW = 1000 * DAY; + const sel = node({ id: "a", lat: 45, lng: -75 }); + function nb(o: Partial): NodeNeighbor { + return { id: "b", publicKey: "pk", nodeType: 2, nodeTypeName: "repeater", iata: "YOW", observationCount: 10, firstSeen: 0, lastSeen: NOW, lat: 46, lng: -76, ...o }; + } + + it("returns empty when nothing is selected", () => { + expect(buildFocusedNeighborEdges(null, [nb({})], NOW).features).toEqual([]); + }); + + it("returns empty when the selected node has no coordinates", () => { + expect(buildFocusedNeighborEdges(node({ id: "a", lat: null, lng: null }), [nb({})], NOW).features).toEqual([]); + }); + + it("draws one edge selected->neighbor with obs, in [lng, lat] order", () => { + const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lat: 46, lng: -76, observationCount: 42, lastSeen: NOW })], NOW); + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.geometry.coordinates).toEqual([[-75, 45], [-76, 46]]); + expect(fc.features[0]!.properties.obs).toBe(42); + expect(fc.features[0]!.properties.selected).toBe(true); + expect(fc.features[0]!.properties.ageDays).toBe(0); + }); + + it("computes ageDays from lastSeen relative to now", () => { + const fc = buildFocusedNeighborEdges(sel, [nb({ id: "b", lastSeen: NOW - 3 * DAY })], NOW); + expect(fc.features[0]!.properties.ageDays).toBeCloseTo(3); + }); + + it("aggregates a neighbor's per-iata rows: sums obs, keeps the freshest lastSeen", () => { + const fc = buildFocusedNeighborEdges(sel, [ + nb({ id: "b", iata: "YOW", observationCount: 30, lastSeen: NOW - 5 * DAY }), + nb({ id: "b", iata: "YYZ", observationCount: 12, lastSeen: NOW - 1 * DAY }), + ], NOW); + expect(fc.features).toHaveLength(1); + expect(fc.features[0]!.properties.obs).toBe(42); + expect(fc.features[0]!.properties.ageDays).toBeCloseTo(1); + }); + + it("skips neighbors without coordinates and any self-referential row", () => { + const fc = buildFocusedNeighborEdges(sel, [ + nb({ id: "b", lat: undefined, lng: undefined }), + nb({ id: "a", lat: 45, lng: -75 }), // self — no zero-length edge + nb({ id: "c", lat: 47, lng: -77, observationCount: 5 }), + ], NOW); + expect(fc.features.map((f) => f.properties.obs)).toEqual([5]); + }); +}); + describe("filterByNodeType", () => { const fc = nodesToFeatureCollection([ node({ id: "r1", nodeTypeName: "repeater" }), From 25bd3ce30ec0fc5c81ecfd8020835b56907779b3 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 18:56:58 -0400 Subject: [PATCH 016/163] Add a neighbour graph under the renamed Analytics tab --- src/App.tsx | 9 +- src/components/BottomNav.tsx | 2 +- src/features/map/neighbor-thresholds.ts | 10 + src/features/map/useMapNeighbors.ts | 5 +- src/features/stats/EChart.tsx | 10 +- src/features/stats/MeshTab.tsx | 12 +- src/features/stats/NeighbourGraph.tsx | 84 ++++++++ src/features/stats/NeighbourGraphTab.tsx | 62 ++++++ src/features/stats/StatsOverview.tsx | 9 +- src/features/stats/StatsSubHeader.tsx | 31 ++- src/features/stats/chartTheme.ts | 14 +- src/features/stats/echarts-setup.ts | 3 +- src/features/stats/neighbour-graph.ts | 205 ++++++++++++++++++ src/features/stats/types.ts | 2 +- src/lib/constants.ts | 2 +- tests/components/BottomNav.test.tsx | 2 +- tests/features/stats/neighbour-graph.test.ts | 212 +++++++++++++++++++ 17 files changed, 640 insertions(+), 34 deletions(-) create mode 100644 src/features/map/neighbor-thresholds.ts create mode 100644 src/features/stats/NeighbourGraph.tsx create mode 100644 src/features/stats/NeighbourGraphTab.tsx create mode 100644 src/features/stats/neighbour-graph.ts create mode 100644 tests/features/stats/neighbour-graph.test.ts diff --git a/src/App.tsx b/src/App.tsx index afb2274c..b07947ff 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -120,7 +120,8 @@ function AppInner() { const isMobile = useIsMobile(); // The URL is the single source of truth for the active tab — back/forward just work, and an // unknown ?tab value falls back to Packets instead of rendering a blank pane. - const tabParam = searchParams.get("tab"); + // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. + const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); const activeTab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as string) : "Packets"; // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -171,7 +172,7 @@ function AppInner() { const next = new URLSearchParams(prev); next.set("tab", tab); // stats sub-state shouldn't haunt the URL on other tabs - if (tab !== "Stats") { + if (tab !== "Analytics") { next.delete("statsTab"); next.delete("observerId"); next.delete("range"); @@ -194,7 +195,7 @@ function AppInner() { setOverlayPacketHash(null); setSearchParams((prev) => { const next = new URLSearchParams(prev); - next.set("tab", "Stats"); + next.set("tab", "Analytics"); next.set("statsTab", "observer"); next.set("observerId", id); return next; @@ -220,7 +221,7 @@ function AppInner() { // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: , Channels: , - Stats: , + Analytics: , Map: , }; diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 5f8898f9..477c08ae 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -3,7 +3,7 @@ import { BottomSheet } from "./BottomSheet"; // Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet. const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const; -const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Stats"] as const; +const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const; // inline SVGs, 20px / 1.6 stroke to match the rest of the icons function Icon({ name }: { name: string }) { diff --git a/src/features/map/neighbor-thresholds.ts b/src/features/map/neighbor-thresholds.ts new file mode 100644 index 00000000..c19557c2 --- /dev/null +++ b/src/features/map/neighbor-thresholds.ts @@ -0,0 +1,10 @@ +// Shared thresholds for styling neighbour links by observation count and freshness. Both the map's +// line layer (useMapNeighbors) and the Analytics neighbour graph read these, so the two views can't +// drift apart. + +// Observation counts are heavily right-skewed, so colour on a log10 axis: ~1 obs red, ~20 yellow, +// ~150+ green. Stops are log10(count) values. +export const OBS_STOPS = { danger: 0, warn: 1.3, green: 2.18 } as const; + +// A link's opacity fades with age — solid when fresh, faint by ~4 weeks (matches the 30-day retention). +export const AGE = { freshDays: 0, freshOp: 0.9, staleDays: 28, staleOp: 0.35 } as const; diff --git a/src/features/map/useMapNeighbors.ts b/src/features/map/useMapNeighbors.ts index a2163cee..d6db109a 100644 --- a/src/features/map/useMapNeighbors.ts +++ b/src/features/map/useMapNeighbors.ts @@ -2,6 +2,7 @@ import { useEffect, useRef } from "react"; import type { Map as MapLibreMap, GeoJSONSource, LineLayerSpecification, ExpressionSpecification } from "maplibre-gl"; import type { FeatureCollection, LineString } from "geojson"; import type { NeighborEdgeProps } from "./node-geojson"; +import { OBS_STOPS, AGE } from "./neighbor-thresholds"; import { NEIGHBORS_SOURCE_ID, NEIGHBORS_LINE_LAYER_ID, NODES_CLUSTER_LAYER_ID } from "./types"; type EdgeFC = FeatureCollection; @@ -14,7 +15,7 @@ function paletteVar(name: string, fallback: string): string { // faint by ~4 weeks (matches the 30-day retention). Ambient "on" edges keep the flat selected/dim split. const NEIGHBOR_OPACITY = [ "case", ["has", "obs"], - ["interpolate", ["linear"], ["get", "ageDays"], 0, 0.9, 28, 0.35], + ["interpolate", ["linear"], ["get", "ageDays"], AGE.freshDays, AGE.freshOp, AGE.staleDays, AGE.staleOp], ["case", ["get", "selected"], 0.9, 0.3], ] as ExpressionSpecification; @@ -23,7 +24,7 @@ const NEIGHBOR_OPACITY = [ function neighborLineColor(danger: string, warn: string, green: string, primary: string): ExpressionSpecification { return [ "case", ["has", "obs"], - ["interpolate", ["linear"], ["log10", ["max", 1, ["get", "obs"]]], 0, danger, 1.3, warn, 2.18, green], + ["interpolate", ["linear"], ["log10", ["max", 1, ["get", "obs"]]], OBS_STOPS.danger, danger, OBS_STOPS.warn, warn, OBS_STOPS.green, green], primary, ] as ExpressionSpecification; } diff --git a/src/features/stats/EChart.tsx b/src/features/stats/EChart.tsx index 71b9f27b..2c2abcc7 100644 --- a/src/features/stats/EChart.tsx +++ b/src/features/stats/EChart.tsx @@ -7,19 +7,27 @@ interface EChartProps { style?: React.CSSProperties; // Map of ECharts event name -> handler (e.g. { click: (p) => ... }). Kept stable by the caller. onEvents?: Record void>; + // Called once with the instance after init, for callers that need imperative control (e.g. the + // neighbour graph dispatches highlight/downplay so selection never re-runs the force layout). + onInit?: (chart: EChartsInstance) => void; } // Thin React wrapper over the core ECharts API: init once, resize via ResizeObserver, dispose on // unmount, and re-apply the (memoized) option with notMerge so theme/data swaps fully replace state. // Hand-rolled on purpose — we avoid the echarts-for-react dependency. -export function EChart({ option, className, style, onEvents }: EChartProps) { +export function EChart({ option, className, style, onEvents, onInit }: EChartProps) { const elRef = useRef(null); const chartRef = useRef(null); + const onInitRef = useRef(onInit); + useEffect(() => { + onInitRef.current = onInit; + }, [onInit]); useEffect(() => { if (!elRef.current) return; const chart = echarts.init(elRef.current, null, { renderer: "canvas" }); chartRef.current = chart; + onInitRef.current?.(chart); const ro = new ResizeObserver(() => chart.resize()); ro.observe(elRef.current); return () => { diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index f2ec64fd..47a55859 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; -import { useChartColors, type ChartColors } from "./chartTheme"; +import { useChartColors, nodeTypeColor } from "./chartTheme"; import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; @@ -23,16 +23,6 @@ function aggregateByHour(points: ObservationPoint[]) { return [...byHour.values()].sort((a, b) => a.hour - b.hour); } -function nodeTypeColor(typeName: string, c: ChartColors): string { - switch (typeName) { - case "companion": return c.primary; - case "repeater": return c.green; - case "room_server": return c.secondary; - case "sensor": return c.warn; - default: return c.primaryDim; - } -} - interface MeshTabProps { range: StatsRange; onSelectObserver: (observerId: string) => void; diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx new file mode 100644 index 00000000..09ab8eca --- /dev/null +++ b/src/features/stats/NeighbourGraph.tsx @@ -0,0 +1,84 @@ +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { EChart } from "./EChart"; +import type { EChartsInstance } from "./echarts-setup"; +import { + neighbourGraphOption, + obsColor, + ageOpacity, + type NeighbourGraph as NeighbourGraphData, + type NeighbourWeight, +} from "./neighbour-graph"; +import type { ChartColors } from "./chartTheme"; + +interface Props { + graph: NeighbourGraphData; + colors: ChartColors; + selectedId: string | null; + focusWeights: Record | null; + onSelect: (id: string | null) => void; +} + +// Presentational force graph. The structural option is memoized on [graph, colors] only, so it (and +// the force layout) rebuilds only for a genuinely new mesh or theme. Selection styling is applied +// imperatively — a link-only merge plus dispatchAction — so it never disturbs settled node positions. +export function NeighbourGraph({ graph, colors, selectedId, focusWeights, onSelect }: Props) { + const chartRef = useRef(null); + const option = useMemo(() => neighbourGraphOption(graph, colors), [graph, colors]); + + const onInit = useCallback( + (chart: EChartsInstance) => { + chartRef.current = chart; + // clicking empty canvas clears the selection + chart.getZr().on("click", (e: { target?: unknown }) => { + if (!e.target) onSelect(null); + }); + }, + [onSelect], + ); + + const onEvents = useMemo( + () => ({ + click: (p: unknown) => { + const param = p as { dataType?: string; data?: { id?: string } }; + if (param.dataType === "edge") return; + const id = param.data?.id; + if (id) onSelect(id); + }, + }), + [onSelect], + ); + + // Selection styling in one pass: recolour the selected node's edges by obs/freshness (link-only + // merge, so node positions survive), then spotlight its adjacency and dim the rest. `option` is a + // dep so both re-apply after a theme/mesh rebuild replaces the chart state. + useEffect(() => { + const chart = chartRef.current; + if (!chart || chart.isDisposed()) return; // a prior instance may linger across a dev remount + + const links = + selectedId && focusWeights + ? graph.links.map((l) => { + const a = graph.nodes[l.source]!.id; + const b = graph.nodes[l.target]!.id; + const otherId = a === selectedId ? b : b === selectedId ? a : null; + const w = otherId ? focusWeights[otherId] : undefined; + if (!w) return l; + return { + ...l, + obs: w.obs, + ageDays: w.ageDays, + lineStyle: { color: obsColor(w.obs, colors), opacity: ageOpacity(w.ageDays), width: 1.8 }, + }; + }) + : graph.links; + chart.setOption({ series: [{ links }] }, { notMerge: false, lazyUpdate: true }); + + chart.dispatchAction({ type: "downplay", seriesIndex: 0 }); + if (selectedId) { + const idx = graph.nodes.findIndex((n) => n.id === selectedId); + if (idx >= 0) chart.dispatchAction({ type: "highlight", seriesIndex: 0, dataIndex: idx }); + } + }, [selectedId, focusWeights, graph, option, colors]); + + return ; +} diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx new file mode 100644 index 00000000..1726486d --- /dev/null +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -0,0 +1,62 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useRegion } from "../../hooks/useRegion"; +import { useMapNodesData } from "../map/useMapNodesData"; +import { getNodeNeighbors } from "../../api/client"; +import { useChartColors } from "./chartTheme"; +import { buildNeighbourGraph, foldNeighbourWeights } from "./neighbour-graph"; +import { NeighbourGraph } from "./NeighbourGraph"; +import { EmptyState } from "../../components/EmptyState"; + +// Most-connected nodes rendered; past this the canvas force layout bogs down. Reuses the map's node +// query (same cache), so the whole region still loads — this only caps what the graph draws. +const CAP = 1000; + +export function NeighbourGraphTab() { + const { iatas, regionKey } = useRegion(); + const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey); + const colors = useChartColors(); + const [selectedId, setSelectedId] = useState(null); + + // a different region is a different mesh — drop any stale selection (adjust-during-render, no effect) + const [region, setRegion] = useState(regionKey); + if (region !== regionKey) { + setRegion(regionKey); + setSelectedId(null); + } + + const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]); + + // Weighted edges for the selected node come from the detail endpoint (shared cache with the map + + // node panel), coloured by obs count and faded by freshness like the map's spotlight. + const { data: neighbours, dataUpdatedAt } = useQuery({ + queryKey: ["node-neighbors", selectedId], + queryFn: () => getNodeNeighbors(selectedId!), + enabled: !!selectedId, + staleTime: 30_000, + }); + // dataUpdatedAt (the fetch time) stands in for "now" — freshness relative to when we pulled the + // data, and pure at render time unlike Date.now(). + const focusWeights = useMemo( + () => (selectedId && neighbours ? foldNeighbourWeights(neighbours, selectedId, dataUpdatedAt) : null), + [selectedId, neighbours, dataUpdatedAt], + ); + + if (isError) return ; + // build only once the pager settles, or the force layout would restart on every streamed page + if (isPaging) return ; + if (graph.nodes.length === 0) return ; + + return ( +
+ {graph.capped && ( +
+ Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. +
+ )} +
+ +
+
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index bd78f3f3..9397ada2 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -4,9 +4,10 @@ import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; import { ObserverTab } from "./ObserverTab"; +import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "observer"]; +const TABS: StatsTab[] = ["mesh", "observer", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -50,11 +51,11 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
- {tab === "mesh" ? ( - - ) : ( + {tab === "mesh" && } + {tab === "observer" && ( )} + {tab === "graph" && }
); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 0d2840b1..e3dd448d 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -21,9 +21,23 @@ function ObserverIcon() { ); } +function GraphIcon() { + return ( + + + + + + + + + ); +} + const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "observer", label: "Observer", icon: }, + { value: "graph", label: "Neighbour Graph", icon: }, ]; const RANGE_OPTIONS = [ @@ -49,12 +63,17 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props ariaLabel="Stats section" size="md" /> - onRangeChange(v as StatsRange)} - ariaLabel="Time range" - /> + {/* the graph is topology, not time-series — no range to pick */} + {tab === "graph" ? ( + + ) : ( + onRangeChange(v as StatsRange)} + ariaLabel="Time range" + /> + )} ); } diff --git a/src/features/stats/chartTheme.ts b/src/features/stats/chartTheme.ts index 7e6a2f51..745c9e57 100644 --- a/src/features/stats/chartTheme.ts +++ b/src/features/stats/chartTheme.ts @@ -53,7 +53,7 @@ export function withAlpha(color: string, a: number): string { return `rgba(${r}, ${g}, ${b}, ${a})`; } -function blend(a: string, b: string, t = 0.5): string { +export function blend(a: string, b: string, t = 0.5): string { const [r1, g1, b1] = parseColor(a); const [r2, g2, b2] = parseColor(b); const mix = (x: number, y: number) => Math.round(x + (y - x) * t); @@ -100,6 +100,18 @@ export function useChartColors(): ChartColors { return useMemo(() => readChartColors(), [paletteRev]); } +// Per-device-type colour, shared by the Mesh "Node types" donut and the neighbour graph so the two +// views stay in sync. Unknown types fall back to a dim primary. +export function nodeTypeColor(typeName: string, c: ChartColors): string { + switch (typeName) { + case "companion": return c.primary; + case "repeater": return c.green; + case "room_server": return c.secondary; + case "sensor": return c.warn; + default: return c.primaryDim; + } +} + // A reusable ECharts tooltip style block bound to the active palette. export function tooltipStyle(c: ChartColors) { return { diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index 319b6f51..b41cf57c 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -3,7 +3,7 @@ // the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps // the core API directly instead. import * as echarts from "echarts/core"; -import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts"; +import { LineChart, BarChart, PieChart, GaugeChart, GraphChart } from "echarts/charts"; import { GridComponent, TitleComponent, @@ -20,6 +20,7 @@ echarts.use([ BarChart, PieChart, GaugeChart, + GraphChart, GridComponent, TitleComponent, TooltipComponent, diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts new file mode 100644 index 00000000..41799059 --- /dev/null +++ b/src/features/stats/neighbour-graph.ts @@ -0,0 +1,205 @@ +import type { NodeSummary, NodeNeighbor } from "../nodes/types"; +import { NODE_TYPE_NAMES, NODE_TYPES } from "../../lib/node-types"; +import { blend, nodeTypeColor, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; +import { OBS_STOPS, AGE } from "../map/neighbor-thresholds"; +import type { EChartsOption } from "./echarts-setup"; + +const MONO = "JetBrains Mono, monospace"; + +// Pure, render-free transform from the region's nodes into an ECharts force-graph shape. Kept +// maplibre- and echarts-free so it stays unit-testable (mirrors features/map/node-geojson.ts). + +export interface GraphNode { + id: string; + name: string; + category: number; // index into the node-type categories, or OTHER_CATEGORY for unknown types + nodeTypeName: string; + degree: number; + symbolSize: number; + label?: { show: boolean }; +} + +export interface GraphLink { + source: number; // index into GraphNode[] + target: number; +} + +export interface NeighbourGraph { + nodes: GraphNode[]; + links: GraphLink[]; + total: number; // nodes before the cap, so callers can show "showing N of total" + capped: boolean; +} + +const OTHER_CATEGORY = NODE_TYPE_NAMES.length; +const MIN_SIZE = 6; +const MAX_SIZE = 34; +const HUB_LABELS = 20; // only the biggest hubs get a persistent label, else 1000 nodes are a text wall + +// Keep the top-`cap` most-connected nodes and their internal edges. Unlike the map's edge builder we +// do NOT require coordinates — the graph is non-geographic, so unlocated nodes belong here too. +export function buildNeighbourGraph(nodes: NodeSummary[], cap: number): NeighbourGraph { + const total = nodes.length; + // rank by neighbour count, id tie-break so the kept set + indices are stable across re-renders + const ranked = [...nodes].sort( + (a, b) => b.knownNeighborCount - a.knownNeighborCount || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0), + ); + const kept = ranked.slice(0, cap); + const capped = total > kept.length; + + const indexById = new Map(); + kept.forEach((n, i) => indexById.set(n.id, i)); + const maxDegree = kept.reduce((m, n) => Math.max(m, n.knownNeighborCount), 0); + + const graphNodes: GraphNode[] = kept.map((n, i) => { + const cat = (NODE_TYPE_NAMES as readonly string[]).indexOf(n.nodeTypeName); + return { + id: n.id, + name: n.name ?? n.id.slice(0, 6), + category: cat === -1 ? OTHER_CATEGORY : cat, + nodeTypeName: n.nodeTypeName, + degree: n.knownNeighborCount, + symbolSize: symbolSize(n.knownNeighborCount, maxDegree), + label: i < HUB_LABELS && n.knownNeighborCount > 0 ? { show: true } : undefined, + }; + }); + + const seen = new Set(); + const links: GraphLink[] = []; + for (const n of kept) { + if (!n.neighborIds) continue; + const from = indexById.get(n.id)!; + for (const otherId of n.neighborIds) { + if (otherId === n.id) continue; // no self-loops + const to = indexById.get(otherId); + if (to === undefined) continue; // skip edges to capped-out / foreign nodes + const key = n.id < otherId ? `${n.id}|${otherId}` : `${otherId}|${n.id}`; + if (seen.has(key)) continue; // undirected — one line per pair + seen.add(key); + links.push({ source: from, target: to }); + } + } + + return { nodes: graphNodes, links, total, capped }; +} + +// sqrt so a few high-degree hubs don't dwarf everything else; floor keeps degree-0 nodes clickable. +function symbolSize(degree: number, maxDegree: number): number { + if (maxDegree <= 0) return MIN_SIZE; + const t = Math.sqrt(degree) / Math.sqrt(maxDegree); + return MIN_SIZE + (MAX_SIZE - MIN_SIZE) * t; +} + +// Observation count → colour, log10 axis red→yellow→green (ports the map's line-colour expression). +export function obsColor(obs: number, c: { danger: string; warn: string; green: string }): string { + const x = Math.log10(Math.max(1, obs)); + if (x <= OBS_STOPS.warn) return blend(c.danger, c.warn, x / OBS_STOPS.warn); + const t = Math.min(1, (x - OBS_STOPS.warn) / (OBS_STOPS.green - OBS_STOPS.warn)); + return blend(c.warn, c.green, t); +} + +// Link age (days) → opacity, solid when fresh, faint by ~4 weeks (ports the map's opacity expression). +export function ageOpacity(ageDays: number): number { + const t = Math.max(0, Math.min(1, ageDays / AGE.staleDays)); + return AGE.freshOp + (AGE.staleOp - AGE.freshOp) * t; +} + +export interface NeighbourWeight { + obs: number; + ageDays: number; +} + +// Fold the /nodes/{id}/neighbors rows (one per neighbour+iata) into a per-neighbour weight: obs summed +// across iatas, age from the freshest lastSeen. Same reduction the map uses in buildFocusedNeighborEdges. +export function foldNeighbourWeights( + neighbors: NodeNeighbor[], + selfId: string, + now: number, +): Record { + const folded = new Map(); + for (const nb of neighbors) { + if (nb.id === selfId) continue; + const prev = folded.get(nb.id); + if (prev) { + prev.obs += nb.observationCount; + prev.lastSeen = Math.max(prev.lastSeen, nb.lastSeen); + } else { + folded.set(nb.id, { obs: nb.observationCount, lastSeen: nb.lastSeen }); + } + } + const out: Record = {}; + for (const [id, w] of folded) { + out[id] = { obs: w.obs, ageDays: Math.max(0, (now - w.lastSeen) / 86_400_000) }; + } + return out; +} + +// One legend/category per device type (in NODE_TYPES order) plus an "Other" bucket for unknowns; the +// GraphNode.category index lines up with this list. +function graphCategories(c: ChartColors) { + return [ + ...NODE_TYPES.map((t) => ({ name: t.label, itemStyle: { color: nodeTypeColor(t.name, c) } })), + { name: "Other", itemStyle: { color: c.primaryDim } }, + ]; +} + +// The themed ECharts force-graph option. Selection styling is applied imperatively (dispatchAction + +// link-only merge) so it never rebuilds this option — see NeighbourGraph.tsx. +export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): EChartsOption { + const big = graph.nodes.length > 500; // settle without animating once the graph gets dense + return { + animation: false, + backgroundColor: "transparent", + tooltip: { + ...tooltipStyle(c), + trigger: "item", + formatter: (p: unknown) => { + const param = p as { dataType?: string; data: Record }; + if (param.dataType === "edge") { + const obs = param.data.obs as number | undefined; + if (obs == null) return ""; // ambient (non-selected) edge — nothing to show + const days = Math.round((param.data.ageDays as number) ?? 0); + return `${obs} obs · ${days === 0 ? "seen today" : `seen ${days}d ago`}`; + } + const d = param.data as unknown as GraphNode; + return `${d.name}\n${d.nodeTypeName} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}`; + }, + }, + legend: [ + { + data: graphCategories(c).map((cat) => cat.name), + bottom: 4, + left: "center", + icon: "circle", + itemWidth: 9, + itemHeight: 9, + textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 }, + inactiveColor: c.textDim, + }, + ], + series: [ + { + type: "graph", + layout: "force", + roam: true, + draggable: true, + scaleLimit: { min: 0.2, max: 8 }, + categories: graphCategories(c), + force: { + repulsion: big ? 60 : 120, + edgeLength: big ? [20, 60] : [40, 90], + gravity: 0.08, + friction: 0.2, + layoutAnimation: !big, + }, + emphasis: { focus: "adjacency", scale: false, label: { show: true }, lineStyle: { width: 1.6 } }, + label: { show: false, position: "right", color: c.textNormal, fontFamily: MONO, fontSize: 9 }, + labelLayout: { hideOverlap: true }, + lineStyle: { color: withAlpha(c.textMuted, 0.22), width: 0.6 }, + itemStyle: { borderColor: c.bgBase, borderWidth: 0.5 }, + data: graph.nodes, + links: graph.links, + }, + ], + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index d09650cf..a5172334 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -78,7 +78,7 @@ export interface ObserverTelemetry { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "observer"; +export type StatsTab = "mesh" | "observer" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 3d2fa491..7c8f15d4 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -14,4 +14,4 @@ export const WS_RECONNECT_MAX_MS = 30_000; export const WS_RECONNECT_JITTER = 0.25; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Stats"] as const; +export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index fe2c1bd6..3ae3aedc 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -24,7 +24,7 @@ describe("BottomNav", () => { }); it("highlights More when an overflow tab is active", () => { - render( {}} />); + render( {}} />); const more = screen.getByText("More").closest("button")!; expect(more.className).toContain("text-primary"); }); diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts new file mode 100644 index 00000000..46d66824 --- /dev/null +++ b/tests/features/stats/neighbour-graph.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect } from "vitest"; +import { buildNeighbourGraph, obsColor, ageOpacity, foldNeighbourWeights } from "../../../src/features/stats/neighbour-graph"; +import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; + +function neighbor(overrides: Partial): NodeNeighbor { + return { + id: "nb", + publicKey: "pk", + nodeType: 1, + nodeTypeName: "repeater", + iata: "YYZ", + observationCount: 1, + firstSeen: 0, + lastSeen: 0, + ...overrides, + }; +} + +const DAY = 86_400_000; + +function node(overrides: Partial): NodeSummary { + return { + id: "n1", + publicKey: "pk", + nodeType: 1, + nodeTypeName: "repeater", + name: "Node 1", + lat: 45, + lng: -75, + iatas: [], + knownNeighborCount: 0, + ...overrides, + }; +} + +// obsColor takes explicit palette colours so the test is theme-independent. +const C = { danger: "#ff0000", warn: "#ffff00", green: "#00ff00" }; + +describe("buildNeighbourGraph", () => { + it("returns an empty graph for no nodes", () => { + expect(buildNeighbourGraph([], 1000)).toEqual({ nodes: [], links: [], total: 0, capped: false }); + }); + + it("includes unlocated nodes (unlike the map's coordinate-gated edges)", () => { + const g = buildNeighbourGraph([node({ id: "a", lat: null, lng: null })], 1000); + expect(g.nodes).toHaveLength(1); + expect(g.nodes[0]!.id).toBe("a"); + }); + + it("ranks by neighbour count and keeps only the top `cap`", () => { + const g = buildNeighbourGraph( + [ + node({ id: "low", knownNeighborCount: 1 }), + node({ id: "high", knownNeighborCount: 5 }), + node({ id: "mid", knownNeighborCount: 3 }), + ], + 2, + ); + expect(g.total).toBe(3); + expect(g.capped).toBe(true); + expect(g.nodes.map((n) => n.id)).toEqual(["high", "mid"]); + }); + + it("is not capped when total <= cap", () => { + const g = buildNeighbourGraph([node({ id: "a" }), node({ id: "b" })], 5); + expect(g.capped).toBe(false); + expect(g.total).toBe(2); + }); + + it("breaks equal-degree ties by id so indices are deterministic", () => { + const g = buildNeighbourGraph( + [node({ id: "b", knownNeighborCount: 2 }), node({ id: "a", knownNeighborCount: 2 })], + 5, + ); + expect(g.nodes.map((n) => n.id)).toEqual(["a", "b"]); + }); + + it("emits each undirected pair once even when both nodes list each other", () => { + const g = buildNeighbourGraph( + [node({ id: "a", neighborIds: ["b"] }), node({ id: "b", neighborIds: ["a"] })], + 5, + ); + expect(g.links).toHaveLength(1); + }); + + it("drops self-loops", () => { + const g = buildNeighbourGraph([node({ id: "a", neighborIds: ["a"] })], 5); + expect(g.links).toEqual([]); + }); + + it("drops edges to ids outside the kept set (foreign or capped-out)", () => { + const g = buildNeighbourGraph( + [ + node({ id: "keep", knownNeighborCount: 9, neighborIds: ["gone", "foreign"] }), + node({ id: "gone", knownNeighborCount: 0 }), + ], + 1, // only "keep" survives the cap + ); + expect(g.links).toEqual([]); + }); + + it("resolves link source/target to indices of the correct kept nodes", () => { + const g = buildNeighbourGraph( + [node({ id: "a", neighborIds: ["b"] }), node({ id: "b" })], + 5, + ); + expect(g.links).toHaveLength(1); + const { source, target } = g.links[0]!; + const ids = [g.nodes[source]!.id, g.nodes[target]!.id].sort(); + expect(ids).toEqual(["a", "b"]); + }); + + it("maps node type to a category index, bucketing unknowns to 'Other'", () => { + const g = buildNeighbourGraph( + [ + node({ id: "c", nodeTypeName: "companion" }), + node({ id: "r", nodeTypeName: "repeater" }), + node({ id: "rs", nodeTypeName: "room_server" }), + node({ id: "s", nodeTypeName: "sensor" }), + node({ id: "x", nodeTypeName: "mystery" }), + ], + 10, + ); + const cat = Object.fromEntries(g.nodes.map((n) => [n.id, n.category])); + expect(cat).toEqual({ c: 0, r: 1, rs: 2, s: 3, x: 4 }); + }); + + it("sizes nodes monotonically by degree, with a floor for degree 0", () => { + const g = buildNeighbourGraph( + [ + node({ id: "hub", knownNeighborCount: 40 }), + node({ id: "mid", knownNeighborCount: 8 }), + node({ id: "leaf", knownNeighborCount: 0 }), + ], + 10, + ); + const size = Object.fromEntries(g.nodes.map((n) => [n.id, n.symbolSize])); + expect(size.hub).toBeGreaterThan(size.mid!); + expect(size.mid).toBeGreaterThan(size.leaf!); + expect(size.leaf).toBeGreaterThan(0); + }); + + it("keeps a node with no neighborIds but contributes no links", () => { + const g = buildNeighbourGraph([node({ id: "a", neighborIds: undefined })], 5); + expect(g.nodes).toHaveLength(1); + expect(g.links).toEqual([]); + }); +}); + +describe("obsColor", () => { + it("is red (danger) at one observation and below", () => { + expect(obsColor(1, C)).toBe("rgb(255, 0, 0)"); + expect(obsColor(0, C)).toBe("rgb(255, 0, 0)"); + }); + + it("saturates to green for high observation counts", () => { + expect(obsColor(1000, C)).toBe("rgb(0, 255, 0)"); + }); + + it("interpolates strictly between the endpoints for mid counts", () => { + const mid = obsColor(5, C); + expect(mid).not.toBe("rgb(255, 0, 0)"); + expect(mid).not.toBe("rgb(0, 255, 0)"); + }); +}); + +describe("ageOpacity", () => { + it("is nearly solid for fresh links", () => { + expect(ageOpacity(0)).toBeCloseTo(0.9); + expect(ageOpacity(-5)).toBeCloseTo(0.9); // clamped + }); + + it("fades to the floor by ~4 weeks and stays there", () => { + expect(ageOpacity(28)).toBeCloseTo(0.35); + expect(ageOpacity(56)).toBeCloseTo(0.35); // clamped + }); + + it("interpolates linearly in between", () => { + expect(ageOpacity(14)).toBeCloseTo(0.625); + }); +}); + +describe("foldNeighbourWeights", () => { + const NOW = 10 * DAY; + + it("returns an empty map for no neighbours", () => { + expect(foldNeighbourWeights([], "self", NOW)).toEqual({}); + }); + + it("sums observations and takes the freshest lastSeen across per-iata rows", () => { + const w = foldNeighbourWeights( + [ + neighbor({ id: "a", iata: "YYZ", observationCount: 3, lastSeen: 8 * DAY }), + neighbor({ id: "a", iata: "YUL", observationCount: 5, lastSeen: 9 * DAY }), + ], + "self", + NOW, + ); + expect(w.a!.obs).toBe(8); + expect(w.a!.ageDays).toBeCloseTo(1); // NOW - 9d (the freshest) + }); + + it("excludes the selected node's own rows", () => { + const w = foldNeighbourWeights([neighbor({ id: "self", observationCount: 4 })], "self", NOW); + expect(w).toEqual({}); + }); + + it("never reports a negative age for a future lastSeen", () => { + const w = foldNeighbourWeights([neighbor({ id: "a", lastSeen: NOW + 5 * DAY })], "self", NOW); + expect(w.a!.ageDays).toBe(0); + }); +}); From 45669d7c260da2fafe1dc3342fcded7d32ae9a48 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 19:53:41 -0400 Subject: [PATCH 017/163] Focus the neighbour graph into an ego view on click --- src/features/stats/NeighbourGraph.tsx | 62 ++------------ src/features/stats/NeighbourGraphTab.tsx | 38 ++++++--- src/features/stats/neighbour-graph.ts | 87 +++++++++++++------- tests/features/stats/neighbour-graph.test.ts | 54 ++++++++---- 4 files changed, 128 insertions(+), 113 deletions(-) diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx index 09ab8eca..3f703d0b 100644 --- a/src/features/stats/NeighbourGraph.tsx +++ b/src/features/stats/NeighbourGraph.tsx @@ -1,34 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef } from "react"; +import { useCallback, useMemo } from "react"; import { EChart } from "./EChart"; -import type { EChartsInstance } from "./echarts-setup"; -import { - neighbourGraphOption, - obsColor, - ageOpacity, - type NeighbourGraph as NeighbourGraphData, - type NeighbourWeight, -} from "./neighbour-graph"; -import type { ChartColors } from "./chartTheme"; +import type { EChartsInstance, EChartsOption } from "./echarts-setup"; interface Props { - graph: NeighbourGraphData; - colors: ChartColors; - selectedId: string | null; - focusWeights: Record | null; + option: EChartsOption; onSelect: (id: string | null) => void; } -// Presentational force graph. The structural option is memoized on [graph, colors] only, so it (and -// the force layout) rebuilds only for a genuinely new mesh or theme. Selection styling is applied -// imperatively — a link-only merge plus dispatchAction — so it never disturbs settled node positions. -export function NeighbourGraph({ graph, colors, selectedId, focusWeights, onSelect }: Props) { - const chartRef = useRef(null); - const option = useMemo(() => neighbourGraphOption(graph, colors), [graph, colors]); - +// Presentational force graph. The caller swaps the whole option between the full mesh and a node's ego +// view, so this stays dumb: render the option, report node clicks, and treat a bare-canvas click as +// "back to the full mesh". No emphasis/dispatch, so dragging a node never flickers. +export function NeighbourGraph({ option, onSelect }: Props) { const onInit = useCallback( (chart: EChartsInstance) => { - chartRef.current = chart; - // clicking empty canvas clears the selection chart.getZr().on("click", (e: { target?: unknown }) => { if (!e.target) onSelect(null); }); @@ -48,37 +32,5 @@ export function NeighbourGraph({ graph, colors, selectedId, focusWeights, onSele [onSelect], ); - // Selection styling in one pass: recolour the selected node's edges by obs/freshness (link-only - // merge, so node positions survive), then spotlight its adjacency and dim the rest. `option` is a - // dep so both re-apply after a theme/mesh rebuild replaces the chart state. - useEffect(() => { - const chart = chartRef.current; - if (!chart || chart.isDisposed()) return; // a prior instance may linger across a dev remount - - const links = - selectedId && focusWeights - ? graph.links.map((l) => { - const a = graph.nodes[l.source]!.id; - const b = graph.nodes[l.target]!.id; - const otherId = a === selectedId ? b : b === selectedId ? a : null; - const w = otherId ? focusWeights[otherId] : undefined; - if (!w) return l; - return { - ...l, - obs: w.obs, - ageDays: w.ageDays, - lineStyle: { color: obsColor(w.obs, colors), opacity: ageOpacity(w.ageDays), width: 1.8 }, - }; - }) - : graph.links; - chart.setOption({ series: [{ links }] }, { notMerge: false, lazyUpdate: true }); - - chart.dispatchAction({ type: "downplay", seriesIndex: 0 }); - if (selectedId) { - const idx = graph.nodes.findIndex((n) => n.id === selectedId); - if (idx >= 0) chart.dispatchAction({ type: "highlight", seriesIndex: 0, dataIndex: idx }); - } - }, [selectedId, focusWeights, graph, option, colors]); - return ; } diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index 1726486d..77d62eaa 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -4,12 +4,12 @@ import { useRegion } from "../../hooks/useRegion"; import { useMapNodesData } from "../map/useMapNodesData"; import { getNodeNeighbors } from "../../api/client"; import { useChartColors } from "./chartTheme"; -import { buildNeighbourGraph, foldNeighbourWeights } from "./neighbour-graph"; +import { buildNeighbourGraph, buildEgoGraph, neighbourGraphOption } from "./neighbour-graph"; import { NeighbourGraph } from "./NeighbourGraph"; import { EmptyState } from "../../components/EmptyState"; // Most-connected nodes rendered; past this the canvas force layout bogs down. Reuses the map's node -// query (same cache), so the whole region still loads — this only caps what the graph draws. +// query (same cache), so the whole region still loads — this only caps what the full mesh draws. const CAP = 1000; export function NeighbourGraphTab() { @@ -26,21 +26,27 @@ export function NeighbourGraphTab() { } const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]); + const selectedNode = useMemo( + () => (selectedId ? nodes.find((n) => n.id === selectedId) ?? null : null), + [selectedId, nodes], + ); - // Weighted edges for the selected node come from the detail endpoint (shared cache with the map + - // node panel), coloured by obs count and faded by freshness like the map's spotlight. + // Selected node's neighbours (shared cache with the map + node panel); dataUpdatedAt stands in for + // "now" so the freshness fade is pure at render time. const { data: neighbours, dataUpdatedAt } = useQuery({ queryKey: ["node-neighbors", selectedId], queryFn: () => getNodeNeighbors(selectedId!), enabled: !!selectedId, staleTime: 30_000, }); - // dataUpdatedAt (the fetch time) stands in for "now" — freshness relative to when we pulled the - // data, and pure at render time unlike Date.now(). - const focusWeights = useMemo( - () => (selectedId && neighbours ? foldNeighbourWeights(neighbours, selectedId, dataUpdatedAt) : null), - [selectedId, neighbours, dataUpdatedAt], - ); + const ego = useMemo(() => { + if (!selectedId || !neighbours) return null; + // fall back to a bare centre if the node was heard from another region (not in the loaded set) + const center = selectedNode ?? { id: selectedId, name: null, nodeTypeName: "" }; + return buildEgoGraph(center, neighbours, dataUpdatedAt); + }, [selectedId, selectedNode, neighbours, dataUpdatedAt]); + + const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]); if (isError) return ; // build only once the pager settles, or the force layout would restart on every streamed page @@ -49,13 +55,19 @@ export function NeighbourGraphTab() { return (
- {graph.capped && ( + {ego ? (
- Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. + Neighbourhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbours — click empty space for the full mesh
+ ) : ( + graph.capped && ( +
+ Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. +
+ ) )}
- +
); diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 41799059..5ad8b898 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -22,6 +22,8 @@ export interface GraphNode { export interface GraphLink { source: number; // index into GraphNode[] target: number; + obs?: number; // weighted-edge fields, set on the ego view's edges (drive colour + freshness fade) + ageDays?: number; } export interface NeighbourGraph { @@ -104,34 +106,50 @@ export function ageOpacity(ageDays: number): number { return AGE.freshOp + (AGE.staleOp - AGE.freshOp) * t; } -export interface NeighbourWeight { - obs: number; - ageDays: number; +const CENTER_SIZE = 30; +const NEIGHBOUR_SIZE = 14; + +function egoNode(id: string, name: string | null, nodeTypeName: string, size: number, degree: number): GraphNode { + const cat = (NODE_TYPE_NAMES as readonly string[]).indexOf(nodeTypeName); + return { + id, + name: name ?? id.slice(0, 6), + category: cat === -1 ? OTHER_CATEGORY : cat, + nodeTypeName, + degree, + symbolSize: size, + label: { show: true }, + }; } -// Fold the /nodes/{id}/neighbors rows (one per neighbour+iata) into a per-neighbour weight: obs summed -// across iatas, age from the freshest lastSeen. Same reduction the map uses in buildFocusedNeighborEdges. -export function foldNeighbourWeights( +// The focused view: one centre node with its neighbours fanned out around it. Neighbours come from +// GET /nodes/{id}/neighbors (one row per neighbour+iata), folded per neighbour — obs summed, age from +// the freshest lastSeen. Edges carry those weights so the option can colour/fade them like the map. +export function buildEgoGraph( + center: { id: string; name: string | null; nodeTypeName: string }, neighbors: NodeNeighbor[], - selfId: string, now: number, -): Record { - const folded = new Map(); +): NeighbourGraph { + const folded = new Map(); for (const nb of neighbors) { - if (nb.id === selfId) continue; + if (nb.id === center.id) continue; const prev = folded.get(nb.id); if (prev) { prev.obs += nb.observationCount; prev.lastSeen = Math.max(prev.lastSeen, nb.lastSeen); } else { - folded.set(nb.id, { obs: nb.observationCount, lastSeen: nb.lastSeen }); + folded.set(nb.id, { name: nb.name ?? null, nodeTypeName: nb.nodeTypeName, obs: nb.observationCount, lastSeen: nb.lastSeen }); } } - const out: Record = {}; - for (const [id, w] of folded) { - out[id] = { obs: w.obs, ageDays: Math.max(0, (now - w.lastSeen) / 86_400_000) }; + + const nodes: GraphNode[] = [egoNode(center.id, center.name, center.nodeTypeName, CENTER_SIZE, folded.size)]; + const links: GraphLink[] = []; + for (const [id, n] of folded) { + // push the link first so target points at the node's about-to-be index + links.push({ source: 0, target: nodes.length, obs: n.obs, ageDays: Math.max(0, (now - n.lastSeen) / 86_400_000) }); + nodes.push(egoNode(id, n.name, n.nodeTypeName, NEIGHBOUR_SIZE, 0)); } - return out; + return { nodes, links, total: nodes.length, capped: false }; } // One legend/category per device type (in NODE_TYPES order) plus an "Other" bucket for unknowns; the @@ -143,10 +161,22 @@ function graphCategories(c: ChartColors) { ]; } -// The themed ECharts force-graph option. Selection styling is applied imperatively (dispatchAction + -// link-only merge) so it never rebuilds this option — see NeighbourGraph.tsx. -export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): EChartsOption { - const big = graph.nodes.length > 500; // settle without animating once the graph gets dense +// The themed ECharts force-graph option, for both the full mesh and the ego (single-node focus) view. +// No hover-adjacency emphasis: it toggles on/off as a dragged node lags the cursor, which flickers the +// graph. Focus is instead the ego view (opts.ego), a clean re-render the caller swaps in. +export function neighbourGraphOption( + graph: NeighbourGraph, + c: ChartColors, + opts: { ego?: boolean } = {}, +): EChartsOption { + const ego = !!opts.ego; + const big = graph.nodes.length > 500; // settle without animating once the full mesh gets dense + // weighted edges (ego view) get an obs→colour, freshness→opacity line; plain mesh edges stay uniform + const links = graph.links.map((l) => + l.obs != null + ? { ...l, lineStyle: { color: obsColor(l.obs, c), opacity: ageOpacity(l.ageDays ?? 0), width: 1.8 } } + : l, + ); return { animation: false, backgroundColor: "transparent", @@ -157,12 +187,13 @@ export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): ECh const param = p as { dataType?: string; data: Record }; if (param.dataType === "edge") { const obs = param.data.obs as number | undefined; - if (obs == null) return ""; // ambient (non-selected) edge — nothing to show + if (obs == null) return ""; // uniform mesh edge — nothing to show const days = Math.round((param.data.ageDays as number) ?? 0); return `${obs} obs · ${days === 0 ? "seen today" : `seen ${days}d ago`}`; } const d = param.data as unknown as GraphNode; - return `${d.name}\n${d.nodeTypeName} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}`; + const type = d.nodeTypeName || "unknown"; + return d.degree > 0 ? `${d.name}\n${type} · ${d.degree} neighbour${d.degree === 1 ? "" : "s"}` : `${d.name}\n${type}`; }, }, legend: [ @@ -185,20 +216,16 @@ export function neighbourGraphOption(graph: NeighbourGraph, c: ChartColors): ECh draggable: true, scaleLimit: { min: 0.2, max: 8 }, categories: graphCategories(c), - force: { - repulsion: big ? 60 : 120, - edgeLength: big ? [20, 60] : [40, 90], - gravity: 0.08, - friction: 0.2, - layoutAnimation: !big, - }, - emphasis: { focus: "adjacency", scale: false, label: { show: true }, lineStyle: { width: 1.6 } }, + force: ego + ? { repulsion: 320, edgeLength: 120, gravity: 0.05, friction: 0.15, layoutAnimation: true } + : { repulsion: big ? 60 : 120, edgeLength: big ? [20, 60] : [40, 90], gravity: 0.08, friction: 0.2, layoutAnimation: !big }, + emphasis: { focus: "none", scale: false }, label: { show: false, position: "right", color: c.textNormal, fontFamily: MONO, fontSize: 9 }, labelLayout: { hideOverlap: true }, lineStyle: { color: withAlpha(c.textMuted, 0.22), width: 0.6 }, itemStyle: { borderColor: c.bgBase, borderWidth: 0.5 }, data: graph.nodes, - links: graph.links, + links, }, ], }; diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts index 46d66824..363d0458 100644 --- a/tests/features/stats/neighbour-graph.test.ts +++ b/tests/features/stats/neighbour-graph.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildNeighbourGraph, obsColor, ageOpacity, foldNeighbourWeights } from "../../../src/features/stats/neighbour-graph"; +import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity } from "../../../src/features/stats/neighbour-graph"; import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function neighbor(overrides: Partial): NodeNeighbor { @@ -180,33 +180,57 @@ describe("ageOpacity", () => { }); }); -describe("foldNeighbourWeights", () => { +describe("buildEgoGraph", () => { const NOW = 10 * DAY; + const center = { id: "c", name: "Center", nodeTypeName: "companion" }; - it("returns an empty map for no neighbours", () => { - expect(foldNeighbourWeights([], "self", NOW)).toEqual({}); + it("puts the center first with its neighbours fanned out from it", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a" }), neighbor({ id: "b" })], NOW); + expect(g.nodes.map((n) => n.id)).toEqual(["c", "a", "b"]); + expect(g.links).toHaveLength(2); + expect(g.links.every((l) => l.source === 0)).toBe(true); + expect(g.nodes[0]!.category).toBe(0); // companion }); - it("sums observations and takes the freshest lastSeen across per-iata rows", () => { - const w = foldNeighbourWeights( + it("shows a label on every node", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a" })], NOW); + expect(g.nodes.every((n) => n.label?.show)).toBe(true); + }); + + it("folds per-iata rows: obs summed, freshest lastSeen wins", () => { + const g = buildEgoGraph( + center, [ neighbor({ id: "a", iata: "YYZ", observationCount: 3, lastSeen: 8 * DAY }), neighbor({ id: "a", iata: "YUL", observationCount: 5, lastSeen: 9 * DAY }), ], - "self", NOW, ); - expect(w.a!.obs).toBe(8); - expect(w.a!.ageDays).toBeCloseTo(1); // NOW - 9d (the freshest) + expect(g.nodes.filter((n) => n.id === "a")).toHaveLength(1); + const link = g.links.find((l) => g.nodes[l.target]!.id === "a")!; + expect(link.obs).toBe(8); + expect(link.ageDays).toBeCloseTo(1); + }); + + it("excludes the center's own rows", () => { + const g = buildEgoGraph(center, [neighbor({ id: "c" })], NOW); + expect(g.nodes).toHaveLength(1); + expect(g.links).toEqual([]); + }); + + it("returns just the center when there are no neighbours", () => { + const g = buildEgoGraph(center, [], NOW); + expect(g.nodes.map((n) => n.id)).toEqual(["c"]); + expect(g.links).toEqual([]); }); - it("excludes the selected node's own rows", () => { - const w = foldNeighbourWeights([neighbor({ id: "self", observationCount: 4 })], "self", NOW); - expect(w).toEqual({}); + it("never reports a negative edge age", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a", lastSeen: NOW + 3 * DAY })], NOW); + expect(g.links[0]!.ageDays).toBe(0); }); - it("never reports a negative age for a future lastSeen", () => { - const w = foldNeighbourWeights([neighbor({ id: "a", lastSeen: NOW + 5 * DAY })], "self", NOW); - expect(w.a!.ageDays).toBe(0); + it("maps a neighbour's node type to a category index", () => { + const g = buildEgoGraph(center, [neighbor({ id: "a", nodeTypeName: "sensor" })], NOW); + expect(g.nodes.find((n) => n.id === "a")!.category).toBe(3); }); }); From bbe404f0c3c9f57137642e29156b4c519d0fa810 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 20:16:28 -0400 Subject: [PATCH 018/163] Wrap the analytics sub-header so the range stays on-screen on mobile --- src/features/stats/Segmented.tsx | 2 +- src/features/stats/StatsSubHeader.tsx | 24 +++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/features/stats/Segmented.tsx b/src/features/stats/Segmented.tsx index 00baa1b7..ee905acd 100644 --- a/src/features/stats/Segmented.tsx +++ b/src/features/stats/Segmented.tsx @@ -34,7 +34,7 @@ export function Segmented({ options, value, onChange, ariaLabel, size = "sm", cl type="button" aria-pressed={active} onClick={() => onChange(o.value)} - className={`flex items-center gap-1.5 rounded font-mono font-semibold tracking-wide transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary ${pad} ${ + className={`flex items-center gap-1.5 whitespace-nowrap rounded font-mono font-semibold tracking-wide transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary ${pad} ${ active ? "bg-primary/15 text-text-bright ring-1 ring-inset ring-primary/30" : "text-text-muted hover:text-text-normal" diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index e3dd448d..14e361ae 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -55,19 +55,21 @@ interface Props { export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { return ( -
- onTabChange(v as StatsTab)} - ariaLabel="Stats section" - size="md" - /> +
+ {/* strip scrolls if too narrow, and the range wraps below rather than being pushed off-screen */} +
+ onTabChange(v as StatsTab)} + ariaLabel="Stats section" + size="md" + /> +
{/* the graph is topology, not time-series — no range to pick */} - {tab === "graph" ? ( - - ) : ( + {tab !== "graph" && ( onRangeChange(v as StatsRange)} From 991f501f7c70c1c15c666ce940864170be711ceb Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 20:52:58 -0400 Subject: [PATCH 019/163] Use a dropdown for the analytics sections on mobile --- src/features/stats/StatsSubHeader.tsx | 31 +++++++++++++++++++++------ 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 14e361ae..7853f9a3 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -1,4 +1,6 @@ import { Segmented } from "./Segmented"; +import { SelectDropdown } from "../../components/SelectDropdown"; +import { useIsMobile } from "../../hooks/useMediaQuery"; import type { StatsRange, StatsTab } from "./types"; function MeshIcon() { @@ -40,6 +42,9 @@ const TAB_OPTIONS = [ { value: "graph", label: "Neighbour Graph", icon: }, ]; +// Same sections, minus icons, for the mobile dropdown (which is text-only). Scales with TAB_OPTIONS. +const TAB_SELECT_OPTIONS = TAB_OPTIONS.map(({ value, label }) => ({ value, label })); + const RANGE_OPTIONS = [ { value: "24h", label: "24h" }, { value: "7d", label: "7d" }, @@ -54,18 +59,30 @@ interface Props { } export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { + const isMobile = useIsMobile(); return (
- {/* strip scrolls if too narrow, and the range wraps below rather than being pushed off-screen */} -
- onTabChange(v as StatsTab)} - ariaLabel="Stats section" - size="md" /> -
+ ) : ( +
+ onTabChange(v as StatsTab)} + ariaLabel="Stats section" + size="md" + /> +
+ )} {/* the graph is topology, not time-series — no range to pick */} {tab !== "graph" && ( Date: Sat, 11 Jul 2026 22:11:16 -0400 Subject: [PATCH 020/163] Gate the neighbour graph to a region and size labels by busyness --- src/features/map/useMapNodesData.ts | 3 ++- src/features/stats/NeighbourGraphTab.tsx | 12 ++++++++- src/features/stats/neighbour-graph.ts | 18 ++++++++++--- src/hooks/useInfinitePages.ts | 10 +++++--- tests/features/stats/neighbour-graph.test.ts | 27 +++++++++++++++++++- 5 files changed, 61 insertions(+), 9 deletions(-) diff --git a/src/features/map/useMapNodesData.ts b/src/features/map/useMapNodesData.ts index 28faecb0..e720d720 100644 --- a/src/features/map/useMapNodesData.ts +++ b/src/features/map/useMapNodesData.ts @@ -7,13 +7,14 @@ const nodeId = (n: NodeSummary) => n.id; // Page the selected region's nodes 50 at a time for the map, so the canvas fills batch by batch // instead of waiting for one big response. Thin wrapper over the shared useInfinitePages (which owns // the auto-chain, dedup, and error handling). Loads once per region; WS updates keep nodes live. -export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string) { +export function useMapNodesData(selectedIatas: string[] | undefined, regionKey: string, opts?: { enabled?: boolean }) { const { items, loadedCount, isPaging, isError } = useInfinitePages({ queryKey: ["map-nodes", regionKey], // Always request neighborIds (just UUIDs) so the neighbor-lines toggle is a pure client-side // render switch over already-loaded data — no refetch when toggling. queryFn: (cursor) => getNodesPage(selectedIatas, { cursor, neighbors: true }), getId: nodeId, + enabled: opts?.enabled, }); return { nodes: items, loadedCount, isPaging, isError }; } diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index 77d62eaa..0b89d0c9 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -14,7 +14,10 @@ const CAP = 1000; export function NeighbourGraphTab() { const { iatas, regionKey } = useRegion(); - const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey); + // "All regions" is 5k+ nodes — too heavy for the canvas force layout, so gate the fetch off and + // prompt for a region instead of freezing the browser. + const isAll = regionKey === "*"; + const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey, { enabled: !isAll }); const colors = useChartColors(); const [selectedId, setSelectedId] = useState(null); @@ -48,6 +51,13 @@ export function NeighbourGraphTab() { const option = useMemo(() => neighbourGraphOption(ego ?? graph, colors, { ego: !!ego }), [ego, graph, colors]); + if (isAll) + return ( + + ); if (isError) return ; // build only once the pager settles, or the force layout would restart on every streamed page if (isPaging) return ; diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 5ad8b898..4f4813b4 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -16,7 +16,7 @@ export interface GraphNode { nodeTypeName: string; degree: number; symbolSize: number; - label?: { show: boolean }; + label?: { show: boolean; fontSize?: number }; } export interface GraphLink { @@ -36,7 +36,16 @@ export interface NeighbourGraph { const OTHER_CATEGORY = NODE_TYPE_NAMES.length; const MIN_SIZE = 6; const MAX_SIZE = 34; -const HUB_LABELS = 20; // only the biggest hubs get a persistent label, else 1000 nodes are a text wall +const HUB_LABELS = 30; // only the biggest hubs get a persistent label, else 1000 nodes are a text wall +const MIN_LABEL = 9; +const MAX_LABEL = 16; + +// Busier hubs get a louder label; sqrt so a few giant hubs don't dwarf the rest of the labelled set. +export function labelSize(degree: number, maxDegree: number): number { + if (maxDegree <= 0) return MIN_LABEL; + const t = Math.sqrt(degree) / Math.sqrt(maxDegree); + return Math.round(MIN_LABEL + (MAX_LABEL - MIN_LABEL) * t); +} // Keep the top-`cap` most-connected nodes and their internal edges. Unlike the map's edge builder we // do NOT require coordinates — the graph is non-geographic, so unlocated nodes belong here too. @@ -62,7 +71,10 @@ export function buildNeighbourGraph(nodes: NodeSummary[], cap: number): Neighbou nodeTypeName: n.nodeTypeName, degree: n.knownNeighborCount, symbolSize: symbolSize(n.knownNeighborCount, maxDegree), - label: i < HUB_LABELS && n.knownNeighborCount > 0 ? { show: true } : undefined, + label: + i < HUB_LABELS && n.knownNeighborCount > 0 + ? { show: true, fontSize: labelSize(n.knownNeighborCount, maxDegree) } + : undefined, }; }); diff --git a/src/hooks/useInfinitePages.ts b/src/hooks/useInfinitePages.ts index dcd45e95..3184c121 100644 --- a/src/hooks/useInfinitePages.ts +++ b/src/hooks/useInfinitePages.ts @@ -13,6 +13,9 @@ interface UseInfinitePagesOptions { // auto-chain every page eagerly (default). false = load only the first page; the caller pulls the // rest via loadMore() (e.g. on scroll) so a large dataset isn't fetched all at once. auto?: boolean; + // false = don't fetch at all (idle query). Lets a caller gate a heavy load off — e.g. the neighbour + // graph skips the ~5k-node "All regions" fetch until a region is picked. + enabled?: boolean; } // Page through a cursor-paginated endpoint. By default it auto-chains page by page as each settles so @@ -20,7 +23,7 @@ interface UseInfinitePagesOptions { // load only the first page and pull the rest on demand via loadMore(). Loads once per key (staleTime // Infinity, no maxPages); dedupes by id because a non-unique cursor can repeat a row across a page // boundary. Shared by the map and the entity tables. -export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, auto = true }: UseInfinitePagesOptions) { +export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, auto = true, enabled = true }: UseInfinitePagesOptions) { const { data, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, isError, isFetchNextPageError, isLoading } = useInfiniteQuery({ queryKey, @@ -28,6 +31,7 @@ export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, au getNextPageParam: (last) => last.nextCursor ?? undefined, initialPageParam: undefined as number | undefined, staleTime: Infinity, + enabled, placeholderData: keepPrevious ? keepPreviousData : undefined, }); @@ -41,8 +45,8 @@ export function useInfinitePages({ queryKey, queryFn, getId, keepPrevious, au // In auto mode, chain to the next page once the current settles — this streams rows batch by batch. // In on-demand mode the caller drives loadMore() instead. useEffect(() => { - if (auto) loadMore(); - }, [auto, loadMore]); + if (auto && enabled) loadMore(); + }, [auto, enabled, loadMore]); const items = useMemo(() => { const seen = new Set(); diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts index 363d0458..cd63512c 100644 --- a/tests/features/stats/neighbour-graph.test.ts +++ b/tests/features/stats/neighbour-graph.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity } from "../../../src/features/stats/neighbour-graph"; +import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity, labelSize } from "../../../src/features/stats/neighbour-graph"; import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function neighbor(overrides: Partial): NodeNeighbor { @@ -145,6 +145,31 @@ describe("buildNeighbourGraph", () => { expect(g.nodes).toHaveLength(1); expect(g.links).toEqual([]); }); + + it("gives busier hubs a larger label font than quieter ones", () => { + const g = buildNeighbourGraph( + [node({ id: "hub", knownNeighborCount: 40 }), node({ id: "small", knownNeighborCount: 2 })], + 10, + ); + const hub = g.nodes.find((n) => n.id === "hub")!; + const small = g.nodes.find((n) => n.id === "small")!; + expect(hub.label?.show).toBe(true); + expect(small.label?.show).toBe(true); + expect(hub.label!.fontSize!).toBeGreaterThan(small.label!.fontSize!); + }); +}); + +describe("labelSize", () => { + it("grows with degree and is largest at the max", () => { + expect(labelSize(40, 40)).toBeGreaterThan(labelSize(5, 40)); + expect(labelSize(20, 40)).toBeGreaterThanOrEqual(labelSize(5, 40)); + }); + + it("clamps to a sane font range and survives maxDegree 0", () => { + expect(labelSize(0, 0)).toBeGreaterThanOrEqual(9); + expect(labelSize(0, 40)).toBeGreaterThanOrEqual(9); + expect(labelSize(1000, 1000)).toBeLessThanOrEqual(16); + }); }); describe("obsColor", () => { From c3979326aa95359fcc466afd1bb2bf3d0f3b16ac Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 11 Jul 2026 22:19:05 -0400 Subject: [PATCH 021/163] Add a name search that spotlights matches and dims the rest of the mesh --- src/features/stats/NeighbourGraph.tsx | 33 +++++++++++++++++--- src/features/stats/NeighbourGraphTab.tsx | 29 ++++++++++++----- src/features/stats/neighbour-graph.ts | 6 ++++ tests/features/stats/neighbour-graph.test.ts | 16 +++++++++- 4 files changed, 72 insertions(+), 12 deletions(-) diff --git a/src/features/stats/NeighbourGraph.tsx b/src/features/stats/NeighbourGraph.tsx index 3f703d0b..41279981 100644 --- a/src/features/stats/NeighbourGraph.tsx +++ b/src/features/stats/NeighbourGraph.tsx @@ -1,18 +1,24 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { EChart } from "./EChart"; import type { EChartsInstance, EChartsOption } from "./echarts-setup"; +import { nodeNameMatches, type GraphNode } from "./neighbour-graph"; interface Props { option: EChartsOption; + nodes: GraphNode[]; + search: string; onSelect: (id: string | null) => void; } // Presentational force graph. The caller swaps the whole option between the full mesh and a node's ego -// view, so this stays dumb: render the option, report node clicks, and treat a bare-canvas click as -// "back to the full mesh". No emphasis/dispatch, so dragging a node never flickers. -export function NeighbourGraph({ option, onSelect }: Props) { +// view; this stays dumb apart from the search overlay. No emphasis/dispatch, so dragging never flickers. +export function NeighbourGraph({ option, nodes, search, onSelect }: Props) { + const chartRef = useRef(null); + const onInit = useCallback( (chart: EChartsInstance) => { + chartRef.current = chart; + // clicking empty canvas returns to the full mesh chart.getZr().on("click", (e: { target?: unknown }) => { if (!e.target) onSelect(null); }); @@ -32,5 +38,24 @@ export function NeighbourGraph({ option, onSelect }: Props) { [onSelect], ); + // Search: highlight matches, dim the rest — a per-node itemStyle/label merge only. The layout is at + // equilibrium, so a style-only merge (no x/y change) leaves node positions put — no relayout, no + // flicker. `option` is a dep so styling re-applies after a region/ego swap replaces the chart state. + useEffect(() => { + const chart = chartRef.current; + if (!chart || chart.isDisposed()) return; + const q = search.trim(); + const data = nodes.map((n) => { + if (!q) return { ...n, itemStyle: { opacity: 1 }, label: n.label ?? { show: false } }; + const match = nodeNameMatches(n.name, q); + return { + ...n, + itemStyle: { opacity: match ? 1 : 0.06 }, + label: match ? { show: true, fontSize: 13 } : { show: false }, + }; + }); + chart.setOption({ series: [{ data }] }, { notMerge: false }); + }, [search, nodes, option]); + return ; } diff --git a/src/features/stats/NeighbourGraphTab.tsx b/src/features/stats/NeighbourGraphTab.tsx index 0b89d0c9..952566b9 100644 --- a/src/features/stats/NeighbourGraphTab.tsx +++ b/src/features/stats/NeighbourGraphTab.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useRegion } from "../../hooks/useRegion"; import { useMapNodesData } from "../map/useMapNodesData"; @@ -7,11 +7,14 @@ import { useChartColors } from "./chartTheme"; import { buildNeighbourGraph, buildEgoGraph, neighbourGraphOption } from "./neighbour-graph"; import { NeighbourGraph } from "./NeighbourGraph"; import { EmptyState } from "../../components/EmptyState"; +import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; // Most-connected nodes rendered; past this the canvas force layout bogs down. Reuses the map's node // query (same cache), so the whole region still loads — this only caps what the full mesh draws. const CAP = 1000; +const SEARCH_FIELDS: SearchFieldOption[] = [{ value: "name", label: "Name" }]; + export function NeighbourGraphTab() { const { iatas, regionKey } = useRegion(); // "All regions" is 5k+ nodes — too heavy for the canvas force layout, so gate the fetch off and @@ -20,14 +23,23 @@ export function NeighbourGraphTab() { const { nodes, loadedCount, isPaging, isError } = useMapNodesData(iatas, regionKey, { enabled: !isAll }); const colors = useChartColors(); const [selectedId, setSelectedId] = useState(null); + const [search, setSearch] = useState(""); + const [searchField, setSearchField] = useState("name"); - // a different region is a different mesh — drop any stale selection (adjust-during-render, no effect) + // a different region is a different mesh — drop any stale selection/search (adjust-during-render) const [region, setRegion] = useState(regionKey); if (region !== regionKey) { setRegion(regionKey); setSelectedId(null); + setSearch(""); } + // focusing a node clears the search so the ego view isn't dimmed by a stale query + const handleSelect = useCallback((id: string | null) => { + setSelectedId(id); + if (id) setSearch(""); + }, []); + const graph = useMemo(() => buildNeighbourGraph(nodes, CAP), [nodes]); const selectedNode = useMemo( () => (selectedId ? nodes.find((n) => n.id === selectedId) ?? null : null), @@ -70,14 +82,17 @@ export function NeighbourGraphTab() { Neighbourhood of {selectedNode?.name ?? selectedId} · {ego.nodes.length - 1} neighbours — click empty space for the full mesh
) : ( - graph.capped && ( -
- Showing the {CAP} most-connected of {graph.total} nodes — narrow to an IATA to see the rest. +
+ + {graph.capped ? `Showing ${CAP} of ${graph.total} nodes — narrow to an IATA for the rest` : `${graph.total} nodes`} + +
+
- ) +
)}
- +
); diff --git a/src/features/stats/neighbour-graph.ts b/src/features/stats/neighbour-graph.ts index 4f4813b4..6586c4f5 100644 --- a/src/features/stats/neighbour-graph.ts +++ b/src/features/stats/neighbour-graph.ts @@ -40,6 +40,12 @@ const HUB_LABELS = 30; // only the biggest hubs get a persistent label, else 100 const MIN_LABEL = 9; const MAX_LABEL = 16; +// Case-insensitive substring match for the graph search; an empty query matches nothing. +export function nodeNameMatches(name: string, query: string): boolean { + const q = query.trim().toLowerCase(); + return q.length > 0 && name.toLowerCase().includes(q); +} + // Busier hubs get a louder label; sqrt so a few giant hubs don't dwarf the rest of the labelled set. export function labelSize(degree: number, maxDegree: number): number { if (maxDegree <= 0) return MIN_LABEL; diff --git a/tests/features/stats/neighbour-graph.test.ts b/tests/features/stats/neighbour-graph.test.ts index cd63512c..f3251f1b 100644 --- a/tests/features/stats/neighbour-graph.test.ts +++ b/tests/features/stats/neighbour-graph.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity, labelSize } from "../../../src/features/stats/neighbour-graph"; +import { buildNeighbourGraph, buildEgoGraph, obsColor, ageOpacity, labelSize, nodeNameMatches } from "../../../src/features/stats/neighbour-graph"; import type { NodeSummary, NodeNeighbor } from "../../../src/features/nodes/types"; function neighbor(overrides: Partial): NodeNeighbor { @@ -159,6 +159,20 @@ describe("buildNeighbourGraph", () => { }); }); +describe("nodeNameMatches", () => { + it("matches a case-insensitive substring", () => { + expect(nodeNameMatches("McCall_Lake", "call")).toBe(true); + expect(nodeNameMatches("YWK Repeater", "repe")).toBe(true); + }); + it("does not match a missing substring", () => { + expect(nodeNameMatches("McCall_Lake", "xyz")).toBe(false); + }); + it("treats an empty/whitespace query as no match", () => { + expect(nodeNameMatches("anything", "")).toBe(false); + expect(nodeNameMatches("anything", " ")).toBe(false); + }); +}); + describe("labelSize", () => { it("grows with degree and is largest at the max", () => { expect(labelSize(40, 40)).toBeGreaterThan(labelSize(5, 40)); From dbb4dcac21707ce483bee13f7cdda01e956d511a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Jul 2026 07:57:52 -0400 Subject: [PATCH 022/163] Add a copy button beside node and observer public keys --- src/components/CopyButton.tsx | 37 ++++++++++++++ src/features/nodes/NodeDetailPanel.tsx | 8 ++- .../observers/ObserverDetailPanel.tsx | 8 ++- tests/components/CopyButton.test.tsx | 49 +++++++++++++++++++ 4 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 src/components/CopyButton.tsx create mode 100644 tests/components/CopyButton.test.tsx diff --git a/src/components/CopyButton.tsx b/src/components/CopyButton.tsx new file mode 100644 index 00000000..cea585f6 --- /dev/null +++ b/src/components/CopyButton.tsx @@ -0,0 +1,37 @@ +import { useState, useCallback } from "react"; +import { VARIANT_CLASSES } from "./badge-utils"; + +// Copy-to-clipboard pill, styled to match the analyzer's "Copy Link" button: flips to a green +// "Copied" state for 1.5s after a click. aria-label defaults to the visible label. +export function CopyButton({ + value, + label = "Copy", + copiedLabel = "Copied", + ariaLabel, + className, +}: { + value: string; + label?: string; + copiedLabel?: string; + ariaLabel?: string; + className?: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [value]); + + return ( + + ); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 7dba5881..57a62e0f 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -2,6 +2,7 @@ import { useQuery } from "@tanstack/react-query"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client"; import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; +import { CopyButton } from "../../components/CopyButton"; import { IataChip } from "../../components/IataChip"; import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; @@ -116,8 +117,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
{node.nodeTypeName}
-
- {node.publicKey} +
+
+ {node.publicKey} +
+
{node.observerId && (
-
- {observer.publicKey} +
+
+ {observer.publicKey} +
+
diff --git a/tests/components/CopyButton.test.tsx b/tests/components/CopyButton.test.tsx new file mode 100644 index 00000000..3d5fb4ab --- /dev/null +++ b/tests/components/CopyButton.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { CopyButton } from "../../src/components/CopyButton"; + +const writeText = vi.fn(); + +beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + writeText.mockClear(); +}); + +describe("CopyButton", () => { + it("shows the default 'Copy' label", () => { + render(); + expect(screen.getByRole("button")).toHaveTextContent("Copy"); + }); + + it("writes the full value to the clipboard on click", () => { + const key = "0123456789abcdef0123456789abcdef"; + render(); + fireEvent.click(screen.getByRole("button")); + expect(writeText).toHaveBeenCalledWith(key); + }); + + it("swaps to 'Copied' after clicking, then reverts", () => { + vi.useFakeTimers(); + try { + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).toHaveTextContent("Copied"); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(button).toHaveTextContent("Copy"); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the provided aria-label for the accessible name", () => { + render(); + expect(screen.getByRole("button", { name: "Copy public key" })).toBeInTheDocument(); + }); +}); From bda714774157a681249278aa393ef244a38968af Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sun, 12 Jul 2026 17:12:08 -0400 Subject: [PATCH 023/163] Add a copy link to node and observer detail panels Deep-linked ?node/?observer selections now survive load: the region reset watches the raw selection instead of the async-resolved regionKey, so slug expansion no longer wipes a restored panel. --- src/App.tsx | 51 ++++++++++++++----- src/components/CopyLinkButton.tsx | 39 ++++++++++++++ src/components/DetailPanel.tsx | 13 +++-- src/features/nodes/NodeDetailPanel.tsx | 2 + .../observers/ObserverDetailPanel.tsx | 2 + src/features/packets/PacketAnalyzerDrawer.tsx | 31 ++--------- tests/App.selectionReset.test.tsx | 40 +++++++++++++++ tests/components/CopyLinkButton.test.tsx | 51 +++++++++++++++++++ 8 files changed, 184 insertions(+), 45 deletions(-) create mode 100644 src/components/CopyLinkButton.tsx create mode 100644 tests/App.selectionReset.test.tsx create mode 100644 tests/components/CopyLinkButton.test.tsx diff --git a/src/App.tsx b/src/App.tsx index b07947ff..7014c18d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -96,19 +96,20 @@ function RegionUrlSync() { return null; } -// Drop the shared node selection when the region changes, so the detail panel doesn't keep showing a -// node that's no longer in the re-queried map/table. Lives inside RegionProvider so it can read useRegion. -function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: () => void }) { - const { regionKey } = useRegion(); - const first = useRef(true); +// Drop the shared node/observer selection when the user changes region, so a detail panel doesn't keep +// showing an entity that's no longer in the re-queried map/table. Watches the raw selection rather than +// the resolved regionKey: the async slug→IATA expansion on load bumps regionKey without any user action, +// and that must NOT count as a change or it would wipe a deep-linked ?node/?observer before it renders. +// Comparing the previous selection (vs a first-run flag) also survives StrictMode's double effect invoke. +export function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: () => void }) { + const { selection } = useRegionSelection(); + const prev = useRef(selection); useEffect(() => { - if (first.current) { - first.current = false; // skip the initial mount; only react to a real change - return; - } + if (prev.current === selection) return; // initial mount, or a re-render that didn't change the selection + prev.current = selection; onRegionChange(); - }, [regionKey, onRegionChange]); + }, [selection, onRegionChange]); return null; } @@ -126,11 +127,12 @@ function AppInner() { // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); + // ?hash / ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button) const [analyzerHash, setAnalyzerHash] = useState(() => searchParams.get("hash")); const [selectedObservationId, setSelectedObservationId] = useState(null); - const [selectedNodeId, setSelectedNodeId] = useState(null); + const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts - const [selectedObserverId, setSelectedObserverId] = useState(null); + const [selectedObserverId, setSelectedObserverId] = useState(() => searchParams.get("observer")); // node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop) const [overlayNodeId, setOverlayNodeId] = useState(null); // packet analyzer shown as a modal over the node panel (clicking a node's observation row) @@ -188,6 +190,27 @@ function AppInner() { setSelectedObserverId(null); }, []); + // Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet + // analyzer's ?hash cleanup). Selecting a different node/observer doesn't touch the URL — the panel's + // Copy Link button rebuilds a fresh link on demand. + const dropSelectionParam = useCallback((key: "node" | "observer") => { + setSearchParams((prev) => { + const next = new URLSearchParams(prev); + next.delete(key); + return next; + }, { replace: true }); + }, [setSearchParams]); + + const handleCloseNode = useCallback(() => { + setSelectedNodeId(null); + dropSelectionParam("node"); + }, [dropSelectionParam]); + + const handleSelectObserver = useCallback((id: string | null) => { + setSelectedObserverId(id); + if (id === null) dropSelectionParam("observer"); + }, [dropSelectionParam]); + // Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected). const handleViewObserverStats = useCallback( (id: string) => { @@ -215,7 +238,7 @@ function AppInner() { const tabContent: Record = { Packets: , Nodes: , - Observers: , + Observers: , Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses @@ -250,7 +273,7 @@ function AppInner() { {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( setSelectedNodeId(null)} + onClose={handleCloseNode} onViewObserver={(observerId) => { handleTabChange("Observers"); setSelectedObserverId(observerId); diff --git a/src/components/CopyLinkButton.tsx b/src/components/CopyLinkButton.tsx new file mode 100644 index 00000000..f074b8ee --- /dev/null +++ b/src/components/CopyLinkButton.tsx @@ -0,0 +1,39 @@ +import { useState, useCallback } from "react"; +import { VARIANT_CLASSES } from "./badge-utils"; + +// Copies a shareable deep link to the current page with the given query params set (built fresh from +// the address bar at click time, so region/other params are preserved). Flips to "Copied" for 1.5s. +export function CopyLinkButton({ + params, + label = "Copy Link", + copiedLabel = "Copied", + ariaLabel, +}: { + params: Record; + label?: string; + copiedLabel?: string; + ariaLabel?: string; +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = useCallback(() => { + const url = new URL(window.location.href); + for (const [key, value] of Object.entries(params)) { + url.searchParams.set(key, value); + } + navigator.clipboard.writeText(url.toString()); + setCopied(true); + setTimeout(() => setCopied(false), 1500); + }, [params]); + + return ( + + ); +} diff --git a/src/components/DetailPanel.tsx b/src/components/DetailPanel.tsx index 427dbfab..02e0823e 100644 --- a/src/components/DetailPanel.tsx +++ b/src/components/DetailPanel.tsx @@ -44,10 +44,12 @@ interface DetailPanelProps { notFound?: boolean; notFoundIcon?: ReactNode; notFoundLabel?: string; + // action rendered in the header, left of the minimize/close controls (e.g. a Copy Link button) + headerAction?: ReactNode; children: ReactNode; } -export function DetailPanel({ title, onClose, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", children }: DetailPanelProps) { +export function DetailPanel({ title, onClose, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) { const [collapsed, setCollapsed] = useState(false); // collapse only touches the mobile overlay: shrink to a bottom bar and hide the body. The md:* // classes below always win at desktop width, so a lingering collapsed state never hides the sidebar. @@ -56,9 +58,12 @@ export function DetailPanel({ title, onClose, collapsible, isLoading, notFound,
{title} -
- {collapsible && setCollapsed((v) => !v)} />} - +
+ {headerAction} +
+ {collapsible && setCollapsed((v) => !v)} />} + +
diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 57a62e0f..a7da6ccb 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -3,6 +3,7 @@ import { getNode, getNodeObservations, getNodeNeighbors } from "../../api/client import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { CopyButton } from "../../components/CopyButton"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; import { IataChip } from "../../components/IataChip"; import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; @@ -99,6 +100,7 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o title="Node Detail" onClose={onClose} collapsible + headerAction={} isLoading={isLoading} notFound={!node} notFoundLabel="Node not found" diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 3c33babe..c935f2fa 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -4,6 +4,7 @@ import { getObserver, getObserverAdverts } from "../../api/client"; import { Badge } from "../../components/Badge"; import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { CopyButton } from "../../components/CopyButton"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { useTick } from "../../hooks/useTick"; @@ -125,6 +126,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi } isLoading={isLoading} notFound={!observer} notFoundLabel="Observer not found" diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 3008989c..e6d6fec6 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -1,11 +1,12 @@ -import { useState, useCallback } from "react"; +import { useCallback } from "react"; import { useSearchParams } from "react-router-dom"; import { CloseButton } from "../../components/CloseButton"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; import type { PacketDetail } from "../../types/api"; import { PayloadType, PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES, type PayloadTypeValue, type RouteTypeValue } from "../../types/enums"; import { Badge } from "../../components/Badge"; import { Tooltip } from "../../components/Tooltip"; -import { VARIANT_CLASSES, payloadTypeVariant } from "../../components/badge-utils"; +import { payloadTypeVariant } from "../../components/badge-utils"; import { ScopeTag } from "../../components/ScopeTag"; import { formatHex, formatPropagation } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; @@ -25,30 +26,6 @@ function decodePayloadHex(encoded: string): string | null { } } -function CopyLinkButton({ packetHash }: { packetHash: string }) { - const [copied, setCopied] = useState(false); - - const handleCopy = useCallback(() => { - const url = new URL(window.location.href); - url.searchParams.set("tab", "Packets"); - url.searchParams.set("hash", packetHash); - navigator.clipboard.writeText(url.toString()); - setCopied(true); - setTimeout(() => setCopied(false), 1500); - }, [packetHash]); - - return ( - - ); -} - interface PacketAnalyzerDrawerProps { detail: PacketDetail | undefined; selectedObservationId: number | null; @@ -91,7 +68,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
Packet Analyzer
- {detail && } + {detail && }
diff --git a/tests/App.selectionReset.test.tsx b/tests/App.selectionReset.test.tsx new file mode 100644 index 00000000..a3311d65 --- /dev/null +++ b/tests/App.selectionReset.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; +import { StrictMode } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { SelectionResetOnRegion } from "../src/App"; +import { RegionProvider, useRegionSelection } from "../src/hooks/useRegion"; + +function RegionChanger() { + const { setSelection } = useRegionSelection(); + return ; +} + +describe("SelectionResetOnRegion", () => { + // Regression: a deep-linked ?node/?observer selection must survive load. The reset watches the raw + // selection (not the resolved regionKey) so the async slug→IATA expansion doesn't count as a change, + // and compares the previous value so StrictMode's double effect invoke can't fire it on mount. + it("does not reset on mount, even under StrictMode's double effect invoke", () => { + const onReset = vi.fn(); + render( + + + + + , + ); + expect(onReset).not.toHaveBeenCalled(); + }); + + it("resets once when the user changes the region selection", () => { + const onReset = vi.fn(); + render( + + + + , + ); + expect(onReset).not.toHaveBeenCalled(); + fireEvent.click(screen.getByText("change")); + expect(onReset).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/components/CopyLinkButton.test.tsx b/tests/components/CopyLinkButton.test.tsx new file mode 100644 index 00000000..fadc2680 --- /dev/null +++ b/tests/components/CopyLinkButton.test.tsx @@ -0,0 +1,51 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { CopyLinkButton } from "../../src/components/CopyLinkButton"; + +const writeText = vi.fn(); + +beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + writable: true, + configurable: true, + }); + writeText.mockClear(); +}); + +describe("CopyLinkButton", () => { + it("shows the default 'Copy Link' label", () => { + render(); + expect(screen.getByRole("button")).toHaveTextContent("Copy Link"); + }); + + it("copies a URL carrying the given params on click", () => { + render(); + fireEvent.click(screen.getByRole("button")); + expect(writeText).toHaveBeenCalledTimes(1); + const copied = new URL(writeText.mock.calls[0][0]); + expect(copied.searchParams.get("tab")).toBe("Nodes"); + expect(copied.searchParams.get("node")).toBe("abc123"); + }); + + it("swaps to 'Copied' after clicking, then reverts", () => { + vi.useFakeTimers(); + try { + render(); + const button = screen.getByRole("button"); + fireEvent.click(button); + expect(button).toHaveTextContent("Copied"); + act(() => { + vi.advanceTimersByTime(1500); + }); + expect(button).toHaveTextContent("Copy Link"); + } finally { + vi.useRealTimers(); + } + }); + + it("uses the provided aria-label for the accessible name", () => { + render(); + expect(screen.getByRole("button", { name: "Copy node link" })).toBeInTheDocument(); + }); +}); From 8786ef3d0a1b0639e4ffd13e590fc9335e9cb01d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 13 Jul 2026 11:14:34 -0400 Subject: [PATCH 024/163] Add .env options for hiding tabs, MeshMapper themes, app name and a GitHub link --- .build/Dockerfile | 3 + .build/docker-entrypoint.sh | 8 +++ .env.example | 11 ++++ docker/.env.example | 11 ++++ docker/docker-compose.yml | 4 ++ public/themes.json | 44 +++++++++++++++ src/App.tsx | 4 +- src/components/AppShell.tsx | 31 ++++++---- src/components/BeaconWordmark.tsx | 7 ++- src/components/BottomNav.tsx | 36 +++++++----- src/components/CloseButton.tsx | 2 +- src/components/DetailPanel.tsx | 2 +- src/components/MultiSelectDropdown.tsx | 2 +- src/components/SearchBar.tsx | 2 +- src/components/SelectDropdown.tsx | 4 +- src/features/channels/MessagePanel.tsx | 2 +- src/features/map/SegmentedControl.tsx | 2 +- src/features/nodes/NodeDetailPanel.tsx | 4 +- .../observers/ObserverDetailPanel.tsx | 2 +- src/features/packets/ObservationCard.tsx | 2 +- src/features/stats/ObserverTab.tsx | 2 +- src/lib/constants.ts | 32 +++++++++++ src/lib/themes.ts | 2 + tests/lib/constants.test.ts | 56 +++++++++++++++++++ 24 files changed, 233 insertions(+), 42 deletions(-) create mode 100644 tests/lib/constants.test.ts diff --git a/.build/Dockerfile b/.build/Dockerfile index ea6449bb..1806c4ca 100644 --- a/.build/Dockerfile +++ b/.build/Dockerfile @@ -7,6 +7,9 @@ ENV VITE_API_BASE=__VITE_API_BASE__ ENV VITE_WS_URL=__VITE_WS_URL__ ENV VITE_MAP_CENTER=__VITE_MAP_CENTER__ ENV VITE_MAP_ZOOM=__VITE_MAP_ZOOM__ +ENV VITE_DISABLED_TABS=__VITE_DISABLED_TABS__ +ENV VITE_ENABLED_THEMES=__VITE_ENABLED_THEMES__ +ENV VITE_APP_NAME=__VITE_APP_NAME__ RUN npm run build FROM caddy:2-alpine diff --git a/.build/docker-entrypoint.sh b/.build/docker-entrypoint.sh index b844b892..4cf2325e 100644 --- a/.build/docker-entrypoint.sh +++ b/.build/docker-entrypoint.sh @@ -6,12 +6,20 @@ WS_URL="${VITE_WS_URL:-ws://localhost:8080/ws}" # Optional map view — leave empty when unset; the app falls back to a world overview. MAP_CENTER="${VITE_MAP_CENTER:-}" MAP_ZOOM="${VITE_MAP_ZOOM:-}" +# Optional branding/customization — empty means default behaviour (all tabs, hidden themes hidden). +DISABLED_TABS="${VITE_DISABLED_TABS:-}" +ENABLED_THEMES="${VITE_ENABLED_THEMES:-}" +# APP_NAME is substituted verbatim; avoid '&' and '|' (sed replacement metachar and delimiter). +APP_NAME="${VITE_APP_NAME:-BEACON}" find /srv -name '*.js' -exec sed -i \ -e "s|__VITE_API_BASE__|${API_BASE}|g" \ -e "s|__VITE_WS_URL__|${WS_URL}|g" \ -e "s|__VITE_MAP_CENTER__|${MAP_CENTER}|g" \ -e "s|__VITE_MAP_ZOOM__|${MAP_ZOOM}|g" \ + -e "s|__VITE_DISABLED_TABS__|${DISABLED_TABS}|g" \ + -e "s|__VITE_ENABLED_THEMES__|${ENABLED_THEMES}|g" \ + -e "s|__VITE_APP_NAME__|${APP_NAME}|g" \ {} + exec "$@" diff --git a/.env.example b/.env.example index adc707a8..69075ed8 100644 --- a/.env.example +++ b/.env.example @@ -11,3 +11,14 @@ # selection has no airport coords; otherwise the map fits bounds over the airports. Unset = world. # VITE_MAP_CENTER=52.5,-96.8 # VITE_MAP_ZOOM=3.2 + +# Hide tabs completely (comma list, case-insensitive). Unset = show all. +# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# VITE_DISABLED_TABS=Map,Traces + +# Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none. +# Options: meshmapper_dark,meshmapper_light +# VITE_ENABLED_THEMES=meshmapper_dark,meshmapper_light + +# Custom top-left app name (default: BEACON). Avoid the & and | characters. +# VITE_APP_NAME=Beacon - MeshMapper diff --git a/docker/.env.example b/docker/.env.example index 732972ef..32f0f0a9 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -11,3 +11,14 @@ VITE_WS_URL=ws://localhost:8080/ws # decimal "lat,lon", VITE_MAP_ZOOM is 0-22. The example below is a Canada-wide view. VITE_MAP_CENTER=52.5,-96.8 VITE_MAP_ZOOM=3.2 + +# Hide tabs completely (comma list, case-insensitive). Leave empty to show all. +# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +VITE_DISABLED_TABS= + +# Reveal normally-hidden themes in the picker (comma list of theme ids). Leave empty for none. +# Options: meshmapper_dark,meshmapper_light +VITE_ENABLED_THEMES= + +# Custom app name shown in the top-left wordmark. Default: BEACON. Avoid the & and | characters. +VITE_APP_NAME= diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index f449012c..840b255e 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,4 +20,8 @@ services: # Optional "All" map view; unset = world overview - VITE_MAP_CENTER=${VITE_MAP_CENTER:-} - VITE_MAP_ZOOM=${VITE_MAP_ZOOM:-} + # Optional branding/customization; unset = all tabs, hidden themes hidden, name "BEACON" + - VITE_DISABLED_TABS=${VITE_DISABLED_TABS:-} + - VITE_ENABLED_THEMES=${VITE_ENABLED_THEMES:-} + - VITE_APP_NAME=${VITE_APP_NAME:-BEACON} restart: unless-stopped diff --git a/public/themes.json b/public/themes.json index 29a089cd..21a161f7 100644 --- a/public/themes.json +++ b/public/themes.json @@ -334,5 +334,49 @@ "--palette-text-muted": "#6B8A8A", "--palette-text-dim": "#4D6A6A" } + }, + { + "id": "meshmapper_dark", + "name": "MeshMapper Dark", + "hidden": true, + "vars": { + "--palette-bg-base": "#121212", + "--palette-bg-surface": "#1E1E1E", + "--palette-bg-raised": "#2A2A2A", + "--palette-border": "#333333", + "--palette-border-subtle": "#242424", + "--palette-primary": "#00D2FF", + "--palette-primary-dim": "#00A5C8", + "--palette-secondary": "#4A9EFF", + "--palette-green": "#28A745", + "--palette-danger": "#DC3545", + "--palette-warn": "#FFC107", + "--palette-text-bright": "#FFFFFF", + "--palette-text-normal": "#E0E0E0", + "--palette-text-muted": "#AAAAAA", + "--palette-text-dim": "#777777" + } + }, + { + "id": "meshmapper_light", + "name": "MeshMapper Light", + "hidden": true, + "vars": { + "--palette-bg-base": "#F4F4F9", + "--palette-bg-surface": "#FFFFFF", + "--palette-bg-raised": "#FFFFFF", + "--palette-border": "#DDDDDD", + "--palette-border-subtle": "#EEEEEE", + "--palette-primary": "#007A94", + "--palette-primary-dim": "#005F73", + "--palette-secondary": "#0062CC", + "--palette-green": "#1E7E34", + "--palette-danger": "#C82333", + "--palette-warn": "#9A6A00", + "--palette-text-bright": "#1A1A1A", + "--palette-text-normal": "#333333", + "--palette-text-muted": "#666666", + "--palette-text-dim": "#767676" + } } ] diff --git a/src/App.tsx b/src/App.tsx index 7014c18d..73637fba 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,7 @@ import { ChannelList } from "./features/channels/ChannelList"; import { EmptyState } from "./components/EmptyState"; import { getPacketDetail } from "./api/client"; import { WsManager } from "./api/ws-manager"; -import { WS_URL, TABS } from "./lib/constants"; +import { WS_URL, ENABLED_TABS } from "./lib/constants"; // Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the // first time someone opens the Map tab instead of bloating the initial bundle. @@ -123,7 +123,7 @@ function AppInner() { // unknown ?tab value falls back to Packets instead of rendering a blank pane. // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); - const activeTab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as string) : "Packets"; + const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 07205a1a..bbaab79b 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -9,7 +9,7 @@ import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; -import { TABS } from "../lib/constants"; +import { ENABLED_TABS, ENABLED_THEME_IDS, isThemeVisible, APP_NAME, GITHUB_URL } from "../lib/constants"; import type { WsManager } from "../api/ws-manager"; // header widgets: WS status, region picker, theme picker @@ -131,7 +131,7 @@ function RegionSelector() { className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${ isAllRegions(selection) ? "text-text-bright bg-primary/10" - : "text-text-muted hover:text-text-normal hover:bg-white/3" + : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`} onClick={() => setSelection(ALL_REGIONS)} > @@ -151,7 +151,7 @@ function RegionSelector() { key={r.slug} type="button" className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${ - checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3" + checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`} onClick={() => toggleRegion(r.slug)} > @@ -172,7 +172,7 @@ function RegionSelector() { key={i.iata} type="button" className={`w-full flex items-center gap-2.5 px-3 py-1.5 text-left text-xs font-mono transition-colors ${ - checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-white/3" + checked ? "text-text-bright bg-primary/10" : "text-text-muted hover:text-text-normal hover:bg-text-normal/3" }`} onClick={() => toggleIata(i.iata)} > @@ -206,7 +206,7 @@ function ThemePicker() { onClick={toggle} > ▾ @@ -215,14 +215,14 @@ function ThemePicker() { > {(close) => ( <> - {themes.map((t) => ( + {themes.filter((t) => isThemeVisible(t, ENABLED_THEME_IDS) || t.id === themeId).map((t) => (
+
+ +
+ {selectedObs && ( diff --git a/src/features/packets/PacketAnalyzerOverlay.tsx b/src/features/packets/PacketAnalyzerOverlay.tsx index ddbd3b83..ec10bf85 100644 --- a/src/features/packets/PacketAnalyzerOverlay.tsx +++ b/src/features/packets/PacketAnalyzerOverlay.tsx @@ -6,11 +6,13 @@ import { ModalOverlay } from "../../components/ModalOverlay"; // Packet analyzer floated over a node detail panel (mirror of NodeDetailOverlay). The node detail it // can stack on top gets no onAnalyzePacket, so the overlay chain stops there instead of recursing. -export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver }: { +export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver, onViewPath, inactive = false }: { detail: PacketDetail | undefined; loading?: boolean; onClose: () => void; onViewObserver: (observerId: string) => void; + onViewPath?: () => void; + inactive?: boolean; }) { const [selectedObservationId, setSelectedObservationId] = useState(null); const [viewNodeId, setViewNodeId] = useState(null); @@ -19,15 +21,15 @@ export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver function onKey(e: KeyboardEvent) { // peel back one layer at a time: the nested node overlay handles its own Escape, so only // close the analyzer once nothing is stacked above it - if (e.key === "Escape" && !viewNodeId) onClose(); + if (e.key === "Escape" && !viewNodeId && !inactive) onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose, viewNodeId]); + }, [onClose, viewNodeId, inactive]); return ( <> - + {viewNodeId && ( diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx new file mode 100644 index 00000000..311416fe --- /dev/null +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import type { PacketDetail } from "../../../src/types/api"; +import { PayloadType } from "../../../src/types/enums"; + +// stub the WebGL map; the modal's own logic is the selector + selection state +vi.mock("../../../src/features/map/PacketPathMap", () => ({ + PacketPathMap: ({ selectedKey }: { selectedKey: string | null }) => ( +
{selectedKey ?? "all"}
+ ), +})); + +import { PacketPathMapModal } from "../../../src/features/map/PacketPathMapModal"; + +// this Node/jsdom combo leaves window.localStorage unavailable; stub it so the modal's +// style-preference read doesn't throw. +beforeEach(() => { + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + }); +}); +afterEach(() => vi.unstubAllGlobals()); + +const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); +const detail = { + header: { payloadType: PayloadType.TEXT, routeType: 1 }, + observations: [ + { id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)] }, + { id: 2, observerId: "obs-bravo", observerName: "Bravo", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("c", -80, 44), hop("d", -76, 46)] }, + ], +} as unknown as PacketDetail; + +describe("PacketPathMapModal", () => { + it("lists All paths plus a row per observer and starts on All", () => { + render( {}} />); + expect(screen.getByText("All paths")).toBeInTheDocument(); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("Bravo")).toBeInTheDocument(); + expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + }); + + it("isolates a path when its row is clicked", () => { + render( {}} />); + fireEvent.click(screen.getByText("Bravo")); + expect(screen.getByTestId("mini-map")).toHaveTextContent("2"); + }); + + it("closes from the close button", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Close path map")); + expect(onClose).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts new file mode 100644 index 00000000..b48fdf3e --- /dev/null +++ b/tests/features/map/packet-path.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect } from "vitest"; +import { buildPacketPaths, PATH_COLORS, packetPathsToFeatures, type PacketPath } from "../../../src/features/map/packet-path"; +import type { Observation, PacketDetail, ResolvedHop } from "../../../src/types/api"; +import { PayloadType } from "../../../src/types/enums"; + +function hop(id: string, lng?: number, lat?: number): ResolvedHop { + const nodes = lng != null && lat != null ? [{ id, publicKey: "pk", longitude: lng, latitude: lat }] : []; + return { confidence: nodes.length ? "high" : "none", nodes }; +} + +function obs(id: number, hops: ResolvedHop[], over: Partial = {}): Observation { + return { + id, observerId: `observer-${id}`, iata: "YYZ", heardAt: 0, + pathLength: { raw: "", hashSize: 1, hopCount: hops.length }, + sourceBroker: "b", resolvedPath: hops, ...over, + } as Observation; +} + +function detail(observations: Observation[], over: Partial = {}): PacketDetail { + return { header: { payloadType: PayloadType.TEXT, routeType: 1 }, observations, ...over } as unknown as PacketDetail; +} + +describe("buildPacketPaths", () => { + it("returns one color-coded path per observation with >=2 located hops", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerName: "Alpha" }), + obs(2, [hop("c", -80, 44), hop("d", -76, 46)]), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(2); + expect(paths[0]).toMatchObject({ key: "1", label: "Alpha", hopCount: 2, color: PATH_COLORS[0] }); + expect(paths[1]).toMatchObject({ key: "2", label: "observer", color: PATH_COLORS[1] }); + expect(paths[0]!.points).toEqual([ + { id: "a", name: undefined, lng: -79, lat: 43 }, + { id: "b", name: undefined, lng: -75, lat: 45 }, + ]); + }); + + it("omits observations that resolve to fewer than 2 located hops", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("x")]), // one unlocated -> 1 point -> omitted + obs(2, [hop("b", -80, 44), hop("c", -76, 46)]), + ]); + expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["2"]); + }); + + it("includes the trace route for TRACE packets", () => { + const d = detail([], { + header: { payloadType: PayloadType.TRACE, routeType: 1 }, + resolvedRoute: [hop("a", -79, 43), hop("b", -75, 45)], + } as unknown as Partial); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]).toMatchObject({ key: "trace", label: "Trace route", hopCount: 2 }); + }); + + it("returns empty when nothing is drawable", () => { + expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)])]))).toEqual([]); + }); +}); + +const P: PacketPath[] = [ + { key: "1", label: "A", hopCount: 3, color: "#111", points: [ + { id: "a", lng: -79, lat: 43 }, { id: "b", lng: -78, lat: 44 }, { id: "c", lng: -77, lat: 45 }, + ] }, + { key: "2", label: "B", hopCount: 2, color: "#222", points: [ + { id: "d", lng: -80, lat: 46 }, { id: "e", lng: -76, lat: 47 }, + ] }, +]; + +describe("packetPathsToFeatures", () => { + it("emits one line per path and one point per hop, with the path color", () => { + const { lines, points, bounds } = packetPathsToFeatures(P, null); + expect(lines.features).toHaveLength(2); + expect(lines.features[0]!.properties).toEqual({ key: "1", color: "#111" }); + expect(lines.features[0]!.geometry.coordinates).toEqual([[-79, 43], [-78, 44], [-77, 45]]); + expect(points.features).toHaveLength(5); + expect(bounds).toHaveLength(5); + }); + + it("marks first/last/middle hops as start/end/mid", () => { + const { points } = packetPathsToFeatures([P[0]!], null); + expect(points.features.map((f) => f.properties.endpoint)).toEqual(["start", "mid", "end"]); + expect(points.features[0]!.properties.label).toBe("a"); + }); + + it("shows only the selected path when a key is given", () => { + const { lines, points } = packetPathsToFeatures(P, "2"); + expect(lines.features.map((f) => f.properties.key)).toEqual(["2"]); + expect(points.features).toHaveLength(2); + }); +}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 8e18df36..09ed12a7 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer"; +import type { PacketDetail } from "../../../src/types/api"; +import { PayloadType, RouteType } from "../../../src/types/enums"; function LocationProbe() { const location = useLocation(); @@ -26,3 +28,39 @@ describe("PacketAnalyzerDrawer close", () => { expect(search).toContain("tab=Packets"); // other params survive }); }); + +const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); + +function makeDetail(resolvedPath: unknown[]): PacketDetail { + return { + packetHash: "abcdef12", + header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TEXT, payloadTypeName: "TXT_MSG", payloadVersion: 1 }, + firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [{ id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: resolvedPath.length }, resolvedPath }], + } as unknown as PacketDetail; +} + +describe("PacketAnalyzerDrawer view-path button", () => { + it("enables the button and calls onViewPath when a path is drawable", () => { + const onViewPath = vi.fn(); + render( + + {}} onViewPath={onViewPath} /> + , + ); + const btn = screen.getByRole("button", { name: /view path on map/i }); + expect(btn).toBeEnabled(); + fireEvent.click(btn); + expect(onViewPath).toHaveBeenCalledOnce(); + }); + + it("disables the button when no path is drawable", () => { + render( + + {}} onViewPath={() => {}} /> + , + ); + expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled(); + }); +}); From 2132e78f28d6623e049be3c205afbfa0a50c64d6 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 18 Jul 2026 09:55:50 -0400 Subject: [PATCH 038/163] Collapse the path map attribution to a bare (i) by default --- src/features/map/PacketPathMap.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index 65573b66..8f347b0d 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -47,6 +47,11 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { mapRef.current = map; map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); map.addControl(new maplibregl.AttributionControl({ compact: true })); + // start the attribution as a bare (i) instead of the wide expanded bar it pops open on load — + // on mobile that bar overlaps the observer list beneath the map (same trick as useMapLibre) + const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib"); + attrib?.classList.add("maplibregl-compact"); + attrib?.classList.remove("maplibregl-compact-show"); const onLoad = () => setReady(true); map.on("load", onLoad); return () => { From 02ec63cc0f97ce7f88bb096b0520a8dc5fc6b305 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 18 Jul 2026 13:37:42 -0400 Subject: [PATCH 039/163] Add path-map deep links, per-observer propagation, and tappable nodes --- src/App.tsx | 24 ++++++- src/features/map/PacketPathMap.tsx | 22 ++++++- src/features/map/PacketPathMapModal.tsx | 29 ++++++-- src/features/map/packet-path.ts | 30 +++++---- src/index.css | 19 +++++- .../features/map/PacketPathMapModal.test.tsx | 27 +++++++- tests/features/map/packet-path.test.ts | 66 +++++++++++-------- 7 files changed, 163 insertions(+), 54 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 65f25b4c..463e4d34 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -141,6 +141,9 @@ function AppInner() { const [overlayPacketHash, setOverlayPacketHash] = useState(null); // packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map") const [pathMapDetail, setPathMapDetail] = useState(null); + const [initialPath] = useState(() => searchParams.get("path")); + const [pathMapInitialKey, setPathMapInitialKey] = useState(null); + const pathLinkHandledRef = useRef(false); // short staleTime: observations keep accruing, so reopening the analyzer should show them // instead of a snapshot frozen at first open @@ -158,6 +161,14 @@ function AppInner() { staleTime: 30_000, }); + // deep link: ?hash opens the analyzer drawer; ?path then opens the path popup once, pre-selected + useEffect(() => { + if (!initialPath || !analyzerDetail || pathLinkHandledRef.current) return; + pathLinkHandledRef.current = true; + setPathMapDetail(analyzerDetail); + setPathMapInitialKey(initialPath); + }, [initialPath, analyzerDetail]); + const handleAnalyze = useCallback((hash: string | null) => { setAnalyzerHash(hash); setSelectedObservationId(null); @@ -274,7 +285,7 @@ function AppInner() { onSelectObservation={setSelectedObservationId} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} - onViewPath={() => { if (analyzerDetail) setPathMapDetail(analyzerDetail); }} + onViewPath={() => { if (analyzerDetail) { setPathMapDetail(analyzerDetail); setPathMapInitialKey(null); } }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( @@ -309,12 +320,19 @@ function AppInner() { handleTabChange("Observers"); setSelectedObserverId(observerId); }} - onViewPath={() => { if (overlayPacketDetail) setPathMapDetail(overlayPacketDetail); }} + onViewPath={() => { if (overlayPacketDetail) { setPathMapDetail(overlayPacketDetail); setPathMapInitialKey(null); } }} inactive={!!pathMapDetail} /> )} {pathMapDetail && ( - setPathMapDetail(null)} /> + { + setPathMapDetail(null); + setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete("path"); return n; }, { replace: true }); + }} + /> )}
diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index 8f347b0d..87d6cea0 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -8,6 +8,7 @@ import type { CircleLayerSpecification, SymbolLayerSpecification, } from "maplibre-gl"; +import type { Point } from "geojson"; import type { PacketPath } from "./packet-path"; import { packetPathsToFeatures } from "./packet-path"; import { resolveMapStyle, DEFAULT_CENTER, DEFAULT_ZOOM, IATA_ZOOM } from "./types"; @@ -52,6 +53,25 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib"); attrib?.classList.add("maplibregl-compact"); attrib?.classList.remove("maplibregl-compact-show"); + // click a path node → popup with its name and raw-decimal coords; pointer cursor on hover + map.on("mouseenter", NODE_LAYER, () => { map.getCanvas().style.cursor = "pointer"; }); + map.on("mouseleave", NODE_LAYER, () => { map.getCanvas().style.cursor = ""; }); + map.on("click", NODE_LAYER, (e) => { + const f = e.features?.[0]; + if (!f) return; + const [lng, lat] = (f.geometry as Point).coordinates as [number, number]; + const el = document.createElement("div"); + const name = document.createElement("div"); + name.className = "pp-popup-name"; + name.textContent = (f.properties?.title as string) ?? ""; + const coords = document.createElement("div"); + coords.className = "pp-popup-coords"; + coords.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; + el.append(name, coords); + // a fresh popup per click; closeOnClick removes the previous one on the same click + new maplibregl.Popup({ closeButton: false, closeOnClick: true, offset: 10 }) + .setLngLat([lng, lat]).setDOMContent(el).addTo(map); + }); const onLoad = () => setReady(true); map.on("load", onLoad); return () => { @@ -109,5 +129,5 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { } }, [ready, paths, selectedKey]); - return
; + return
; } diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index 751fb359..3209c251 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -2,6 +2,8 @@ import { useEffect, useMemo, useState } from "react"; import type { PacketDetail } from "../../types/api"; import { ModalOverlay } from "../../components/ModalOverlay"; import { CloseButton } from "../../components/CloseButton"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; +import { formatPropagation } from "../../lib/formatters"; import { buildPacketPaths } from "./packet-path"; import { PacketPathMap } from "./PacketPathMap"; import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types"; @@ -9,8 +11,8 @@ import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types"; // Closable mini-map of a packet's resolved path(s). "All paths" overlays every observation's route; // clicking an observer isolates its path. Lives over the analyzer (no tab switch), so closing it // returns the user exactly where they were. -function Row({ active, color, label, hops, onClick }: { - active: boolean; color?: string; label: string; hops?: number; onClick: () => void; +function Row({ active, color, label, meta, onClick }: { + active: boolean; color?: string; label: string; meta?: string; onClick: () => void; }) { return ( ); } -export function PacketPathMapModal({ detail, onClose }: { detail: PacketDetail; onClose: () => void }) { +export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { + detail: PacketDetail; + onClose: () => void; + initialSelectedKey?: string | null; +}) { const paths = useMemo(() => buildPacketPaths(detail), [detail]); - const [selectedKey, setSelectedKey] = useState(null); // null = All paths + const [selectedKey, setSelectedKey] = useState( + // deep-link value that matches a known path isolates it; anything else (incl. "all") shows All + () => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null), + ); const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []); useEffect(() => { @@ -45,7 +54,13 @@ export function PacketPathMapModal({ detail, onClose }: { detail: PacketDetail;
Packet Path - +
+ ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all" })} + ariaLabel="Copy path link" + /> + +
@@ -62,7 +77,7 @@ export function PacketPathMapModal({ detail, onClose }: { detail: PacketDetail; active={selectedKey === p.key} color={p.color} label={p.label} - hops={p.hopCount} + meta={formatPropagation(p.propagationMs)} onClick={() => setSelectedKey(p.key)} /> ))} diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 3ce55320..9715fdbe 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -10,9 +10,9 @@ export interface PathPoint { } export interface PacketPath { - key: string; // observation id as string, or "trace" - label: string; // observer name, or a truncated observer id, or "Trace route" - hopCount: number; + key: string; // observerId, or "trace" + label: string; // observer name, a truncated observer id, or "Trace route" + propagationMs?: number; // packet's propagation to this observer (ms); absent for the trace route color: string; points: PathPoint[]; } @@ -50,22 +50,25 @@ function observerLabel(obs: Observation): string { } // One drawable path per observation (and the trace route for TRACE packets) that resolves to >=2 -// located hops. Colors are assigned by result index so they stay contiguous after filtering. +// located hops, keyed by observerId and sorted fastest-first. Colors are assigned after sorting so +// the selector swatch matches the drawn line. export function buildPacketPaths(detail: PacketDetail): PacketPath[] { - const paths: PacketPath[] = []; - const push = (key: string, label: string, hopCount: number, points: PathPoint[]) => { + const raw: Omit[] = []; + const add = (key: string, label: string, propagationMs: number | undefined, points: PathPoint[]) => { if (points.length < 2) return; - paths.push({ key, label, hopCount, color: PATH_COLORS[paths.length % PATH_COLORS.length]!, points }); + raw.push({ key, label, propagationMs, points }); }; for (const obs of detail.observations) { - push(String(obs.id), observerLabel(obs), obs.pathLength.hopCount, pathPoints(obs.resolvedPath)); + add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(obs.resolvedPath)); } if (detail.header.payloadType === PayloadType.TRACE && detail.resolvedRoute) { - const pts = pathPoints(detail.resolvedRoute); - push("trace", "Trace route", detail.resolvedRoute.length, pts); + add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute)); } - return paths; + + // fastest first; missing propagation (incl. the trace route) sorts last + raw.sort((a, b) => ((a.propagationMs ?? Infinity) - (b.propagationMs ?? Infinity)) || 0); // || 0: two Infinity props → NaN; keep insertion order + return raw.map((p, i) => ({ ...p, color: PATH_COLORS[i % PATH_COLORS.length]! })); } export interface PathLineProps { @@ -76,7 +79,8 @@ export interface PathLineProps { export interface PathNodeProps { key: string; color: string; - label: string; + label: string; // short label for the map (truncated id when unnamed) + title: string; // untruncated name/id for the click popup endpoint: "start" | "end" | "mid"; } @@ -101,7 +105,7 @@ export function packetPathsToFeatures( const endpoint = i === 0 ? "start" : i === path.points.length - 1 ? "end" : "mid"; points.push({ type: "Feature", - properties: { key: path.key, color: path.color, label: pt.name ?? pt.id.slice(0, 6), endpoint }, + properties: { key: path.key, color: path.color, label: pt.name ?? pt.id.slice(0, 6), title: pt.name ?? pt.id, endpoint }, geometry: { type: "Point", coordinates: [pt.lng, pt.lat] }, }); bounds.push([pt.lng, pt.lat]); diff --git a/src/index.css b/src/index.css index f8227623..cf8b878a 100644 --- a/src/index.css +++ b/src/index.css @@ -107,7 +107,7 @@ } /* MapLibre controls ship light chrome; re-skin to the app's dark tokens when the active basemap - is dark (data-dark set on the map container in MapView). Glyphs are baked-in dark SVGs, so they + is dark (data-dark set on each map container — MapView and the packet-path mini-map). Glyphs are baked-in dark SVGs, so they are inverted rather than recolored. Light basemaps (Liberty/Light) keep the default chrome. */ [data-dark="true"] .maplibregl-ctrl-group { background: var(--color-bg-raised); @@ -134,3 +134,20 @@ [data-dark="true"] .maplibregl-ctrl-attrib a { color: var(--color-text-muted); } +/* Path-node popup: dark chrome to match the controls, plus name/coords typography. */ +[data-dark="true"] .maplibregl-popup-content { + background: var(--color-bg-raised); + color: var(--color-text-normal); + border: 1px solid var(--color-border); + box-shadow: none; +} +[data-dark="true"] .maplibregl-popup-anchor-top .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-top-left .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-top-right .maplibregl-popup-tip { border-bottom-color: var(--color-bg-raised); } +[data-dark="true"] .maplibregl-popup-anchor-bottom .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-bottom-left .maplibregl-popup-tip, +[data-dark="true"] .maplibregl-popup-anchor-bottom-right .maplibregl-popup-tip { border-top-color: var(--color-bg-raised); } +[data-dark="true"] .maplibregl-popup-anchor-left .maplibregl-popup-tip { border-right-color: var(--color-bg-raised); } +[data-dark="true"] .maplibregl-popup-anchor-right .maplibregl-popup-tip { border-left-color: var(--color-bg-raised); } +.pp-popup-name { font-weight: 600; } +.pp-popup-coords { font-family: ui-monospace, monospace; font-size: 11px; color: var(--color-text-muted); margin-top: 2px; } diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index 311416fe..c626adc8 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -27,8 +27,8 @@ const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as c const detail = { header: { payloadType: PayloadType.TEXT, routeType: 1 }, observations: [ - { id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)] }, - { id: 2, observerId: "obs-bravo", observerName: "Bravo", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("c", -80, 44), hop("d", -76, 46)] }, + { id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], propagationTimeMs: 100 }, + { id: 2, observerId: "obs-bravo", observerName: "Bravo", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("c", -80, 44), hop("d", -76, 46)], propagationTimeMs: 480 }, ], } as unknown as PacketDetail; @@ -44,7 +44,13 @@ describe("PacketPathMapModal", () => { it("isolates a path when its row is clicked", () => { render( {}} />); fireEvent.click(screen.getByText("Bravo")); - expect(screen.getByTestId("mini-map")).toHaveTextContent("2"); + expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + }); + + it("shows each observer's propagation", () => { + render( {}} />); + expect(screen.getByText("0.100s")).toBeInTheDocument(); // formatPropagation(100) + expect(screen.getByText("0.480s")).toBeInTheDocument(); }); it("closes from the close button", () => { @@ -53,4 +59,19 @@ describe("PacketPathMapModal", () => { fireEvent.click(screen.getByLabelText("Close path map")); expect(onClose).toHaveBeenCalledOnce(); }); + + it("pre-selects the observer from initialSelectedKey", () => { + render( {}} initialSelectedKey="obs-bravo" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + }); + + it("falls back to All when initialSelectedKey isn't a known path", () => { + render( {}} initialSelectedKey="nope" />); + expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + }); + + it("renders a copy-link button", () => { + render( {}} />); + expect(screen.getByRole("button", { name: "Copy path link" })).toBeInTheDocument(); + }); }); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index b48fdf3e..79514eb0 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -21,49 +21,52 @@ function detail(observations: Observation[], over: Partial = {}): } describe("buildPacketPaths", () => { - it("returns one color-coded path per observation with >=2 located hops", () => { + it("keys each path by observerId and carries propagation, fastest first", () => { const d = detail([ - obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerName: "Alpha" }), - obs(2, [hop("c", -80, 44), hop("d", -76, 46)]), + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }), + obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", observerName: "Fast", propagationTimeMs: 100 }), ]); const paths = buildPacketPaths(d); - expect(paths).toHaveLength(2); - expect(paths[0]).toMatchObject({ key: "1", label: "Alpha", hopCount: 2, color: PATH_COLORS[0] }); - expect(paths[1]).toMatchObject({ key: "2", label: "observer", color: PATH_COLORS[1] }); - expect(paths[0]!.points).toEqual([ - { id: "a", name: undefined, lng: -79, lat: 43 }, - { id: "b", name: undefined, lng: -75, lat: 45 }, - ]); + expect(paths.map((p) => p.key)).toEqual(["obs-fast", "obs-slow"]); // fastest first + expect(paths[0]).toMatchObject({ key: "obs-fast", label: "Fast", propagationMs: 100, color: PATH_COLORS[0] }); + expect(paths[1]).toMatchObject({ key: "obs-slow", propagationMs: 900, color: PATH_COLORS[1] }); // colors follow sort order + }); + + it("sorts missing propagation and the trace route last", () => { + const d = detail( + [ + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation + obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }), + ], + { + header: { payloadType: PayloadType.TRACE, routeType: 1 }, + resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)], + } as unknown as Partial, + ); + const keys = buildPacketPaths(d).map((p) => p.key); + expect(keys[0]).toBe("obs-fast"); + expect(keys).toContain("obs-none"); + expect(keys[keys.length - 1]).toBe("trace"); // trace (no propagation) last }); it("omits observations that resolve to fewer than 2 located hops", () => { const d = detail([ - obs(1, [hop("a", -79, 43), hop("x")]), // one unlocated -> 1 point -> omitted - obs(2, [hop("b", -80, 44), hop("c", -76, 46)]), + obs(1, [hop("a", -79, 43), hop("x")], { observerId: "obs-1" }), + obs(2, [hop("b", -80, 44), hop("c", -76, 46)], { observerId: "obs-2" }), ]); - expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["2"]); - }); - - it("includes the trace route for TRACE packets", () => { - const d = detail([], { - header: { payloadType: PayloadType.TRACE, routeType: 1 }, - resolvedRoute: [hop("a", -79, 43), hop("b", -75, 45)], - } as unknown as Partial); - const paths = buildPacketPaths(d); - expect(paths).toHaveLength(1); - expect(paths[0]).toMatchObject({ key: "trace", label: "Trace route", hopCount: 2 }); + expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["obs-2"]); }); it("returns empty when nothing is drawable", () => { - expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)])]))).toEqual([]); + expect(buildPacketPaths(detail([obs(1, [hop("a", -79, 43)], { observerId: "obs-1" })]))).toEqual([]); }); }); const P: PacketPath[] = [ - { key: "1", label: "A", hopCount: 3, color: "#111", points: [ + { key: "1", label: "A", color: "#111", points: [ { id: "a", lng: -79, lat: 43 }, { id: "b", lng: -78, lat: 44 }, { id: "c", lng: -77, lat: 45 }, ] }, - { key: "2", label: "B", hopCount: 2, color: "#222", points: [ + { key: "2", label: "B", color: "#222", points: [ { id: "d", lng: -80, lat: 46 }, { id: "e", lng: -76, lat: 47 }, ] }, ]; @@ -89,4 +92,15 @@ describe("packetPathsToFeatures", () => { expect(lines.features.map((f) => f.properties.key)).toEqual(["2"]); expect(points.features).toHaveLength(2); }); + + it("carries the untruncated node identity as title", () => { + const path: PacketPath = { key: "k", label: "L", color: "#111", points: [ + { id: "abcdef123456", lng: -79, lat: 43 }, // no name -> title is the full id + { id: "z", name: "Repeater North", lng: -78, lat: 44 }, // named -> title is the name + ] }; + const { points } = packetPathsToFeatures([path], null); + expect(points.features[0]!.properties.title).toBe("abcdef123456"); + expect(points.features[0]!.properties.label).toBe("abcdef"); // label stays truncated for the map + expect(points.features[1]!.properties.title).toBe("Repeater North"); + }); }); From 6af9d652aa8db025e70bbe3c952d96d3d7bfec6f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Sat, 18 Jul 2026 22:28:01 -0400 Subject: [PATCH 040/163] Keep path map at a fixed height on narrow screens --- src/features/map/PacketPathMapModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index 3209c251..69619be8 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -64,7 +64,7 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: {
-
+
From 88ae52bdf47b14bdedd49f97f72146edf09a53c0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 23 Jul 2026 22:53:02 -0400 Subject: [PATCH 041/163] Show packet source and destination on the path map and analyzer --- src/features/map/packet-path.ts | 15 +++- src/features/packets/PacketAnalyzerDrawer.tsx | 20 ++---- src/features/packets/payload-renderers.tsx | 61 ++++++++++------ src/types/api.ts | 4 ++ src/types/ws.ts | 3 + tests/features/map/packet-path.test.ts | 72 ++++++++++++++++--- .../packets/PacketAnalyzerDrawer.test.tsx | 32 +++++++++ .../packets/payload-renderers.test.tsx | 36 ++++++++++ 8 files changed, 194 insertions(+), 49 deletions(-) diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 9715fdbe..13b4f8a7 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -59,10 +59,19 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { raw.push({ key, label, propagationMs, points }); }; - for (const obs of detail.observations) { - add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(obs.resolvedPath)); + const isTrace = detail.header.payloadType === PayloadType.TRACE; + // TRACE observations now resolve to the same hops as detail.resolvedRoute, so their per-observation + // lines would just duplicate the single "Trace route" below — draw only that one for traces. + if (!isTrace) { + for (const obs of detail.observations) { + // full chain: source → relay hops → destination; missing/unlocated endpoints drop out in pathPoints + const chain = [obs.resolvedSource, ...obs.resolvedPath, obs.resolvedDestination].filter( + (h): h is ResolvedHop => h != null, + ); + add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); + } } - if (detail.header.payloadType === PayloadType.TRACE && detail.resolvedRoute) { + if (isTrace && detail.resolvedRoute) { add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute)); } diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 4301cc51..3ccaac28 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -201,22 +201,12 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o )} - {/* Path data — for TRACE the path bytes are per-hop SNR samples, so show them raw */} + {/* Path data — TRACE's pathBytes are now its trace path hashes (matching hashSize/hopCount + and resolvedPath), so it resolves through PathData like every other type. */} {selectedObs?.pathBytes && ( - {detail.header.payloadType === PayloadType.TRACE ? ( - <> -
Path SNR Data
-
- {selectedObs.pathBytes.toUpperCase()} -
- - ) : ( - <> -
Path Data
- - - )} +
Path Data
+
)} @@ -232,7 +222,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && (
- +
)} diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index e36e3cc3..87902ebc 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -13,6 +13,14 @@ interface PayloadProps { payload: Record; } +// the packet's resolved endpoints + node-open callback, threaded from the analyzer through the +// envelope/anon renderers so From/To/Dest hashes resolve to node blocks like path hops do. +interface EndpointProps { + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; + onViewNode?: (nodeId: string) => void; +} + function SectionLabel({ children }: { children: React.ReactNode }) { return
{children}
; } @@ -80,11 +88,11 @@ function EncryptedIndicator() { ); } -function EncryptedEnvelope({ payload, headerSlot, children }: { +function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, resolvedDestination, onViewNode }: { payload: Record; headerSlot?: ReactNode; children: (decrypted: Record) => ReactNode; -}) { +} & EndpointProps) { const destinationHash = payload.destinationHash as string | undefined; const sourceHash = payload.sourceHash as string | undefined; const cipherMac = payload.cipherMac as string | undefined; @@ -96,13 +104,19 @@ function EncryptedEnvelope({ payload, headerSlot, children }: {
{destinationHash && ( - To + To + {resolvedDestination + ? + : } ({destinationHash.length / 2}B) )} {sourceHash && ( - From + From + {resolvedSource + ? + : } ({sourceHash.length / 2}B) )} @@ -316,9 +330,9 @@ function GroupTextPayload({ payload }: PayloadProps) { ); } -function TextPayload({ payload }: PayloadProps) { +function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.message != null && ( @@ -335,9 +349,9 @@ function TextPayload({ payload }: PayloadProps) { ); } -function RequestPayload({ payload }: PayloadProps) { +function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.requestTypeName != null && ( @@ -357,9 +371,9 @@ function RequestPayload({ payload }: PayloadProps) { ); } -function ResponsePayload({ payload }: PayloadProps) { +function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.tag != null && ( @@ -387,9 +401,9 @@ function AckPayload({ payload }: PayloadProps) { ); } -function PathPayload({ payload }: PayloadProps) { +function PathPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => } ); @@ -652,15 +666,18 @@ function GenericPayload({ payload }: PayloadProps) { ); } -function AnonReqPayload({ payload }: PayloadProps) { +function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadProps & EndpointProps) { const destination = payload.destination as number | undefined; const ephemeralPubKey = payload.ephemeralPubKey as string | undefined; + const destLabel = destination != null ? `0x${destination.toString(16).toUpperCase().padStart(2, "0")}` : ""; return (
{destination != null && ( - 0x{destination.toString(16).toUpperCase().padStart(2, "0")} + {resolvedDestination + ? + : {destLabel}} )} {ephemeralPubKey && ( @@ -672,21 +689,21 @@ function AnonReqPayload({ payload }: PayloadProps) { // routes payload.type to the right renderer -export function PayloadBreakdown({ payload, resolvedRoute, onViewNode }: { +export function PayloadBreakdown({ payload, resolvedRoute, resolvedSource, resolvedDestination, onViewNode }: { payload: Record; resolvedRoute?: ResolvedHop[]; // trace packets only — packet-level, not part of parsedPayload - onViewNode?: (nodeId: string) => void; -}) { +} & EndpointProps) { + const endpoints: EndpointProps = { resolvedSource, resolvedDestination, onViewNode }; switch (payload.type) { case "ADVERT": return ; case "TRACE": return ; case "GROUP_TEXT": return ; - case "TEXT_MESSAGE": return ; - case "REQUEST": return ; - case "RESPONSE": return ; - case "ANON_REQUEST": return ; + case "TEXT_MESSAGE": return ; + case "REQUEST": return ; + case "RESPONSE": return ; + case "ANON_REQUEST": return ; case "ACK": return ; - case "PATH": return ; + case "PATH": return ; case "CONTROL": return ; case "DISCOVER_REQ": return ; case "DISCOVER_RESP": return ; diff --git a/src/types/api.ts b/src/types/api.ts index f4b68be5..86dfa735 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -69,6 +69,10 @@ export interface Observation { }; sourceBroker: string; resolvedPath: ResolvedHop[]; + // the packet's logical endpoints, resolved from the payload; separate from the relay + // resolvedPath. Absent for payload types with no addressed endpoint (GRP_TXT/GRP_DATA/TRACE). + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; } export interface PacketHeader { diff --git a/src/types/ws.ts b/src/types/ws.ts index bbff970f..ea65403a 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -63,6 +63,9 @@ export interface WsPacketObservation { // per-hop resolved path; populated only when the connection opts in via configure{resolvePath}, // null otherwise. Same shape as the REST Observation.resolvedPath. resolvedPath?: ResolvedHop[] | null; + // the packet's logical endpoints, same shape as REST; null when the payload type carries none. + resolvedSource?: ResolvedHop | null; + resolvedDestination?: ResolvedHop | null; }; }; } diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 79514eb0..053f7ab6 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -32,21 +32,75 @@ describe("buildPacketPaths", () => { expect(paths[1]).toMatchObject({ key: "obs-slow", propagationMs: 900, color: PATH_COLORS[1] }); // colors follow sort order }); - it("sorts missing propagation and the trace route last", () => { + it("sorts observations with missing propagation after timed ones", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation + obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }), + ]); + const keys = buildPacketPaths(d).map((p) => p.key); + expect(keys[0]).toBe("obs-fast"); + expect(keys[keys.length - 1]).toBe("obs-none"); // missing propagation sorts last + }); + + it("prepends resolvedSource and appends resolvedDestination to the observation line", () => { + const d = detail([ + obs(1, [hop("relay", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]); + }); + + it("draws a source->destination line for a directed message with no relay hops", () => { + const d = detail([ + obs(1, [], { + observerId: "obs-1", propagationTimeMs: 50, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "dst"]); + }); + + it("dedupes a resolvedSource that matches the first relay hop", () => { + const d = detail([ + obs(1, [hop("src", -79, 43), hop("relay", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]); + }); + + it("skips an unresolved endpoint rather than drawing a misleading line", () => { + const d = detail([ + obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src"), // unlocated — no coords + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]); + }); + + it("draws only the trace route for TRACE packets, suppressing per-observation lines", () => { const d = detail( - [ - obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation - obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }), - ], + [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)], } as unknown as Partial, ); - const keys = buildPacketPaths(d).map((p) => p.key); - expect(keys[0]).toBe("obs-fast"); - expect(keys).toContain("obs-none"); - expect(keys[keys.length - 1]).toBe("trace"); // trace (no propagation) last + expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]); }); it("omits observations that resolve to fewer than 2 located hops", () => { diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 09ed12a7..bbe42550 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -64,3 +64,35 @@ describe("PacketAnalyzerDrawer view-path button", () => { expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled(); }); }); + +describe("PacketAnalyzerDrawer TRACE path data", () => { + // After beacon-server's trace-path fix (7a58a07) a TRACE observation's pathBytes are the trace's + // own path hashes (with matching hashSize/hopCount and a real resolvedPath), not raw SNR bytes — + // so it must render as resolved Path Data, not under the old "Path SNR Data" label. + function traceDetail(): PacketDetail { + return { + packetHash: "abcdef12", + header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TRACE, payloadTypeName: "TRACE", payloadVersion: 1 }, + firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [{ + id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", + pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, + pathBytes: "abcd", + resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], + }], + } as unknown as PacketDetail; + } + + it("renders TRACE path bytes as resolved Path Data, not raw 'Path SNR Data'", () => { + render( + + {}} /> + , + ); + expect(screen.queryByText("Path SNR Data")).not.toBeInTheDocument(); + expect(screen.getByText("Path Data")).toBeInTheDocument(); + // the first trace hash renders as a resolved hop block, tinted green for high confidence + expect(screen.getAllByText("AB").some((el) => el.className.includes("text-green"))).toBe(true); + }); +}); diff --git a/tests/features/packets/payload-renderers.test.tsx b/tests/features/packets/payload-renderers.test.tsx index 192829a1..f759115d 100644 --- a/tests/features/packets/payload-renderers.test.tsx +++ b/tests/features/packets/payload-renderers.test.tsx @@ -46,6 +46,42 @@ describe("PayloadBreakdown — trace resolvedRoute overlay", () => { }); }); +describe("PayloadBreakdown — resolved source/destination endpoints", () => { + const envelope = { + type: "TEXT_MESSAGE", + sourceHash: "aa", + destinationHash: "bb", + cipherMac: "0011", + ciphertext: "deadbeef", + decrypted: null, + }; + const resolvedSource: ResolvedHop = { confidence: "high", nodes: [{ id: "s1", publicKey: "aa", name: "Alice" }] }; + const resolvedDestination: ResolvedHop = { confidence: "high", nodes: [{ id: "d1", publicKey: "bb", name: "Bob" }] }; + + it("makes the resolved From/To hashes clickable node blocks", () => { + const onViewNode = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "BB" })); // To → destination node + expect(onViewNode).toHaveBeenCalledWith("d1"); + fireEvent.click(screen.getByRole("button", { name: "AA" })); // From → source node + expect(onViewNode).toHaveBeenCalledWith("s1"); + }); + + it("falls back to a plain hash badge when the endpoint did not resolve", () => { + render(); + expect(screen.queryByRole("button", { name: "BB" })).not.toBeInTheDocument(); + expect(screen.getByText("BB")).toBeInTheDocument(); + }); + + it("resolves an ANON_REQUEST destination hash to a node block", () => { + const onViewNode = vi.fn(); + const anon = { type: "ANON_REQUEST", destination: 0xbb, ephemeralPubKey: "cc" }; + render(); + fireEvent.click(screen.getByRole("button", { name: "0xBB" })); + expect(onViewNode).toHaveBeenCalledWith("d1"); + }); +}); + describe("PayloadBreakdown — DISCOVER_REQ", () => { // Backend emits DISCOVER as a top-level parsedPayload.type (not nested under CONTROL). // See beacon-server internal/ingest/packet.go parsedDiscoverReq. From 7bf30da5c1f04c002e16740111bc33ab18cd902c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 23 Jul 2026 23:05:39 -0400 Subject: [PATCH 042/163] Add top advertisers to Mesh stats and a Talkers leaderboard tab --- src/api/client.ts | 10 +++++++ src/features/stats/MeshTab.tsx | 15 ++++++++++- src/features/stats/StatsOverview.tsx | 4 ++- src/features/stats/StatsSubHeader.tsx | 10 +++++++ src/features/stats/TalkersTab.tsx | 39 +++++++++++++++++++++++++++ src/features/stats/types.ts | 19 ++++++++++++- src/features/stats/useStats.ts | 20 ++++++++++++++ tests/api/client.test.ts | 26 +++++++++++++++++- 8 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 src/features/stats/TalkersTab.tsx diff --git a/src/api/client.ts b/src/api/client.ts index 116ca581..6cac33ad 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -9,6 +9,8 @@ import type { PayloadBreakdownItem, TopNode, TopObserver, + TopAdvertiser, + TopTalker, RadioPreset, ScopeStats, ObserverTelemetry, @@ -282,6 +284,14 @@ export function getTopObservers(iatas?: string[], since?: number, limit = 10): P return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit }); } +export function getTopAdvertisers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-advertisers", { iatas: iatasParam(iatas), since, limit }); +} + +export function getTopTalkers(iatas?: string[], since?: number, limit = 10): Promise { + return request("/stats/top-talkers", { iatas: iatasParam(iatas), since, limit }); +} + export function getRadioPresets(iatas?: string[]): Promise { return request("/stats/radio-presets", { iatas: iatasParam(iatas) }); } diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 47a55859..3a1bbf9e 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useTopAdvertisers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; @@ -37,6 +37,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const payload = usePayloadBreakdown(range); const topNodes = useTopNodes(10); const topObservers = useTopObservers(range, 8); + const topAdvertisers = useTopAdvertisers(range, 10); const radioPresets = useRadioPresets(); const scopes = useScopes(); const nodeTypes = useNodeTypes(); @@ -55,6 +56,17 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { ); const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); + const advertiserRows = useMemo( + () => + (topAdvertisers.data ?? []).map((a) => ({ + name: a.nodeName ?? a.nodeId.slice(0, 8), + value: a.advertCount, + color: nodeTypeColor(a.nodeTypeName, colors), + })), + [topAdvertisers.data, colors], + ); + const advertisersOption = useMemo(() => leaderboardOption(advertiserRows, colors), [advertiserRows, colors]); + const payloadItems = useMemo( () => (payload.data ?? []) @@ -140,6 +152,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isError={payload.isError} isEmpty={payloadItems.length === 0} /> + Top advertisers · {range}} height={208} option={advertisersOption} isLoading={topAdvertisers.isLoading} isError={topAdvertisers.isError} isEmpty={advertiserRows.length === 0} /> {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 9397ada2..b9c689b1 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -3,11 +3,12 @@ import { useSearchParams } from "react-router-dom"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; +import { TalkersTab } from "./TalkersTab"; import { ObserverTab } from "./ObserverTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "talkers", "observer", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -52,6 +53,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
{tab === "mesh" && } + {tab === "talkers" && } {tab === "observer" && ( )} diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 7853f9a3..9917d311 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -23,6 +23,15 @@ function ObserverIcon() { ); } +function TalkersIcon() { + return ( + + + + + ); +} + function GraphIcon() { return ( @@ -38,6 +47,7 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, + { value: "talkers", label: "Talkers", icon: }, { value: "observer", label: "Observer", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx new file mode 100644 index 00000000..e427ecbf --- /dev/null +++ b/src/features/stats/TalkersTab.tsx @@ -0,0 +1,39 @@ +import { useMemo } from "react"; +import { useChartColors } from "./chartTheme"; +import { useTopTalkers } from "./useStats"; +import { leaderboardOption } from "./chartOptions"; +import { ChartCard } from "./cards"; +import type { StatsRange } from "./types"; + +interface TalkersTabProps { + range: StatsRange; +} + +// Top talkers by decrypted channel-message count. Grouped by sender display-name (see TopTalker), +// hence the "by name" caption — its own tab so the leaderboard can breathe and later grow. +export function TalkersTab({ range }: TalkersTabProps) { + const colors = useChartColors(); + const topTalkers = useTopTalkers(range, 20); + + const rows = useMemo( + () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), + [topTalkers.data, colors], + ); + const option = useMemo(() => leaderboardOption(rows, colors), [rows, colors]); + // grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing + const height = Math.max(260, rows.length * 34 + 24); + + return ( +
+ Top talkers · {range}} + right={by name} + height={height} + option={option} + isLoading={topTalkers.isLoading} + isError={topTalkers.isError} + isEmpty={rows.length === 0} + /> +
+ ); +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index a5172334..6bf24fc4 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -40,6 +40,23 @@ export interface TopObserver { observationCount: number; } +export interface TopAdvertiser { + nodeId: string; + nodeName: string | null; + nodeType: number; + nodeTypeName: string; + iata: string; + advertCount: number; + lastHeard: number; // epoch ms +} + +// grouped by decrypted sender display-name, not node identity: same-named pubkeys merge, a rename splits +export interface TopTalker { + senderName: string; + messageCount: number; + lastSent: number; // epoch ms +} + export interface RadioPreset { preset: string; // "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7" iata: string; @@ -78,7 +95,7 @@ export interface ObserverTelemetry { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "observer" | "graph"; +export type StatsTab = "mesh" | "talkers" | "observer" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 836f3db2..9f61c0bc 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -6,6 +6,8 @@ import { getPayloadBreakdown, getTopNodes, getTopObservers, + getTopAdvertisers, + getTopTalkers, getRadioPresets, getStatsScopes, getStatsNodeTypes, @@ -71,6 +73,24 @@ export function useTopObservers(range: StatsRange, limit = 10) { }); } +export function useTopAdvertisers(range: StatsRange, limit = 10) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-top-advertisers", regionKey, range, limit], + queryFn: () => getTopAdvertisers(iatas, sinceFor(range), limit), + ...common, + }); +} + +export function useTopTalkers(range: StatsRange, limit = 10) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-top-talkers", regionKey, range, limit], + queryFn: () => getTopTalkers(iatas, sinceFor(range), limit), + ...common, + }); +} + export function useRadioPresets() { const { iatas, regionKey } = useRegion(); return useQuery({ diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 0cdb1416..0a9e1270 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getStatsNodeTypes } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes } from "../../src/api/client"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -381,6 +381,30 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("15"); }); + it("hits /stats/top-advertisers with iatas/since/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/top-advertisers"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("10"); + }); + + it("hits /stats/top-talkers with iatas/since/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getTopTalkers(["YOW"], 1700000000000, 8); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/top-talkers"); + expect(url.searchParams.get("iatas")).toBe("YOW"); + expect(url.searchParams.get("since")).toBe("1700000000000"); + expect(url.searchParams.get("limit")).toBe("8"); + }); + it("hits /stats/node-types with the region's IATAs", async () => { const getUrl = mockFetchOnce([{ nodeType: 2, nodeTypeName: "repeater", count: 12 }]); From 23bacde2b84c3a9d587a9406d35dd96913e6bbf7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 23 Jul 2026 23:10:31 -0400 Subject: [PATCH 043/163] Add public key prefix search to the nodes table --- src/api/client.ts | 2 ++ src/features/nodes/NodeFilterBar.tsx | 1 + src/features/nodes/NodeTable.tsx | 1 + tests/api/client.test.ts | 10 ++++++++++ 4 files changed, 14 insertions(+) diff --git a/src/api/client.ts b/src/api/client.ts index 6cac33ad..39a9f1fa 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -211,6 +211,7 @@ export function getNodesPage( limit?: number; type?: string; name?: string; + pubkeyPrefix?: string; // case-insensitive hex prefix; server matches and validates supportsMultibytePaths?: "true" | "false"; supportsMultibyteTraces?: "true" | "false"; neighbors?: boolean; // include each node's neighborIds (?neighbors=true) @@ -222,6 +223,7 @@ export function getNodesPage( limit: params?.limit ?? DEFAULT_PAGE_SIZE, typeName: params?.type, name: params?.name, + pubkeyPrefix: params?.pubkeyPrefix, supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, diff --git a/src/features/nodes/NodeFilterBar.tsx b/src/features/nodes/NodeFilterBar.tsx index d93b475f..24d5dfd7 100644 --- a/src/features/nodes/NodeFilterBar.tsx +++ b/src/features/nodes/NodeFilterBar.tsx @@ -12,6 +12,7 @@ const MULTIBYTE_OPTIONS = [ const SEARCH_FIELDS: SearchFieldOption[] = [ { value: "name", label: "Name" }, + { value: "pubkey", label: "Public Key" }, ]; // "" means no filter (Any) diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 35f0a77d..dbfc0ed4 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -152,6 +152,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable cursor, type: typeFilter || undefined, name: searchField === "name" ? search || undefined : undefined, + pubkeyPrefix: searchField === "pubkey" ? search || undefined : undefined, supportsMultibytePaths: pathsFilter || undefined, supportsMultibyteTraces: tracesFilter || undefined, }), diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 0a9e1270..d950b08f 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -84,6 +84,16 @@ describe("getNodesPage", () => { expect(url).toContain("limit=50"); }); + it("forwards the pubkeyPrefix search param", async () => { + const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); + + await getNodesPage(["YYZ"], { pubkeyPrefix: "a1b2" }); + + const url = getUrl(); + expect(url).toContain("/nodes"); + expect(url).toContain("pubkeyPrefix=a1b2"); + }); + it("forwards the Nodes-table filters (type maps to typeName, multibyte flags)", async () => { const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); From 913151c61076837a79b4d61a6d8aa2949af4d5d1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 23 Jul 2026 23:31:05 -0400 Subject: [PATCH 044/163] Show device clock drift on the node detail panel --- src/features/nodes/NodeDetailPanel.tsx | 8 +++++- src/features/nodes/types.ts | 6 +++++ src/lib/formatters.ts | 15 +++++++++++ tests/features/nodes/NodeDetailPanel.test.tsx | 27 +++++++++++++++++++ tests/lib/formatters.test.ts | 17 ++++++++++++ 5 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index f3bf2090..4debae6a 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -5,7 +5,7 @@ import { DetailPanel, Section, Field } from "../../components/DetailPanel"; import { CopyButton } from "../../components/CopyButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import { IataChip } from "../../components/IataChip"; -import { formatHex, formatSnr, snrLevel, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { formatHex, formatSnr, snrLevel, formatRadio, formatClockDrift, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import type { NodeObservation, NodeNeighbor } from "./types"; @@ -161,6 +161,12 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o } /> } /> {node.lastAdvertAt != null && } />} + {node.clockDriftSeconds != null && ( + {formatClockDrift(node.clockDriftSeconds)}} + /> + )}
diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index a748f445..97c552e3 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -31,6 +31,12 @@ export interface Node extends NodeSummary { firstSeen: number; // epoch ms lastSeen: number; // epoch ms metadata: Record | null; + // Clock drift, repeaters/room servers only; absent for other types or before a qualifying advert. + // Device minus server time in seconds (+ve = device ahead). clockCheckedAt == lastAdvertAt. + // clockOutOfSync is the server's verdict against its threshold — don't recompute it client-side. + clockDriftSeconds?: number; + clockOutOfSync?: boolean; + clockCheckedAt?: number; // epoch ms } // First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination). diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 6bb6fdfd..e28664a5 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -52,6 +52,21 @@ export function formatUptime(seconds: number): string { return `${m}m`; } +// Signed device-clock drift for the node detail, e.g. "+42s ahead", "-1h 1m behind", "in sync". +// formatUptime floors to whole minutes and is unsigned, so it can't render sub-minute drift. +// +ve = device clock ahead of the server (matches clockDriftSeconds). +export function formatClockDrift(seconds: number): string { + if (seconds === 0) return "in sync"; + const dir = seconds > 0 ? "ahead" : "behind"; + const sign = seconds > 0 ? "+" : "-"; + const s = Math.abs(seconds); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + const mag = h > 0 ? `${h}h ${m}m` : m > 0 ? `${m}m ${sec}s` : `${sec}s`; + return `${sign}${mag} ${dir}`; +} + export function formatBattery(volts: number): string { return `${volts.toFixed(2)}V`; } diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 19162b69..22992a24 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -86,3 +86,30 @@ describe("NodeDetailPanel neighbors", () => { expect(await screen.findByText("No known neighbors")).toBeInTheDocument(); }); }); + +describe("NodeDetailPanel clock drift", () => { + it("shows a repeater's clock drift in amber when the server flags it out of sync", async () => { + mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 432, clockOutOfSync: true, clockCheckedAt: 2 }); + + renderPanel(); + + const drift = await screen.findByText("+7m 12s ahead"); + expect(drift.className).toContain("text-warn"); + }); + + it("shows an in-sync drift in green", async () => { + mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 20, clockOutOfSync: false, clockCheckedAt: 2 }); + + renderPanel(); + + const drift = await screen.findByText("+20s ahead"); + expect(drift.className).toContain("text-green"); + }); + + it("omits clock drift entirely when the node reports none", async () => { + renderPanel(); + + await screen.findByText("Timestamps"); + expect(screen.queryByText(/Clock drift/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index 59a905d9..d94464c7 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -7,6 +7,7 @@ import { snrLevel, formatPropagation, formatCount, + formatClockDrift, } from "../../src/lib/formatters"; describe("formatHex", () => { @@ -103,3 +104,19 @@ describe("formatPropagation", () => { expect(formatPropagation(null)).toBe("—"); }); }); + +describe("formatClockDrift", () => { + it("labels a zero drift as in sync", () => { + expect(formatClockDrift(0)).toBe("in sync"); + }); + + it("shows sub-minute drift with a sign and direction word", () => { + expect(formatClockDrift(42)).toBe("+42s ahead"); + expect(formatClockDrift(-45)).toBe("-45s behind"); + }); + + it("breaks out minutes and hours, dropping seconds once hours appear", () => { + expect(formatClockDrift(432)).toBe("+7m 12s ahead"); // 7*60 + 12 + expect(formatClockDrift(-3670)).toBe("-1h 1m behind"); // 3670 -> 1h 1m + }); +}); From badd919e0d0729dafd3f768daf9276b7e9224055 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 06:16:51 -0400 Subject: [PATCH 045/163] Only plot unambiguously-resolved path endpoints on the map --- src/features/map/packet-path.ts | 13 +++++++++++-- tests/features/map/packet-path.test.ts | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 13b4f8a7..4c415e95 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -49,6 +49,14 @@ function observerLabel(obs: Observation): string { return obs.observerName ?? obs.observerId.slice(0, 8); } +// The map plots one marker per hop, so an ambiguous endpoint (a 1-byte prefix matching several +// candidate nodes) would force us to guess which node actually sent/received the packet. Only plot +// source/destination when the backend resolved it unambiguously ("high"); ambiguous/unresolved +// endpoints are left off the line — the analyzer still lists every candidate for them. +function confidentEndpoint(hop: ResolvedHop | undefined): ResolvedHop | undefined { + return hop?.confidence === "high" ? hop : undefined; +} + // One drawable path per observation (and the trace route for TRACE packets) that resolves to >=2 // located hops, keyed by observerId and sorted fastest-first. Colors are assigned after sorting so // the selector swatch matches the drawn line. @@ -64,8 +72,9 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { // lines would just duplicate the single "Trace route" below — draw only that one for traces. if (!isTrace) { for (const obs of detail.observations) { - // full chain: source → relay hops → destination; missing/unlocated endpoints drop out in pathPoints - const chain = [obs.resolvedSource, ...obs.resolvedPath, obs.resolvedDestination].filter( + // full chain: source → relay hops → destination. Endpoints only when unambiguously resolved + // (see confidentEndpoint); missing/unlocated hops drop out in pathPoints. + const chain = [confidentEndpoint(obs.resolvedSource), ...obs.resolvedPath, confidentEndpoint(obs.resolvedDestination)].filter( (h): h is ResolvedHop => h != null, ); add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 053f7ab6..43860ba5 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -92,6 +92,27 @@ describe("buildPacketPaths", () => { expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]); }); + it("skips an ambiguous endpoint instead of guessing one of its candidates", () => { + // 1-byte source/dest prefixes resolve to several candidate nodes; the backend flags this + // "ambiguous" so the client shouldn't pick one and draw it as a definitive endpoint. + const ambiguousSource: ResolvedHop = { + confidence: "ambiguous", + nodes: [ + { id: "cand-a", publicKey: "pa", longitude: -71, latitude: 46 }, + { id: "cand-b", publicKey: "pb", longitude: -73, latitude: 48 }, + ], + }; + const d = detail([ + obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: ambiguousSource, + resolvedDestination: hop("dst", -77, 45), // high confidence — still drawn + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]); + }); + it("draws only the trace route for TRACE packets, suppressing per-observation lines", () => { const d = detail( [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], From 948cfa25b093d85f5f67a4b2b483a7c2f2ae088d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 06:19:19 -0400 Subject: [PATCH 046/163] Fix public-key node search field switching and non-hex input --- src/features/nodes/NodeTable.tsx | 21 ++++++++++++++++----- src/features/nodes/node-search.ts | 12 ++++++++++++ tests/features/nodes/node-search.test.ts | 23 +++++++++++++++++++++++ 3 files changed, 51 insertions(+), 5 deletions(-) create mode 100644 src/features/nodes/node-search.ts create mode 100644 tests/features/nodes/node-search.test.ts diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index dbfc0ed4..60179f75 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -14,6 +14,7 @@ import { ObserverIcon } from "../../components/ObserverIcon"; import { DataTable, type Column } from "../../components/DataTable"; import { LoadingPill } from "../../components/LoadingPill"; import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; +import { nodeSearchParams } from "./node-search"; import { patchNodeSummary } from "./node-updates"; import type { NodeSummary } from "./types"; import type { CursorPage } from "../../types/api"; @@ -138,9 +139,19 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable useTick(); + // switching the field flips what the box means (a name vs a hex prefix), so stale text mustn't carry over + const handleSearchFieldChange = useCallback((field: string) => { + setSearchField(field); + setSearch(""); + }, []); + + // derive the actual server params (name vs pubkeyPrefix, hex-guarded) and key the query on THOSE, + // so toggling the field with an empty box is a no-op and a name never gets sent as a hex prefix + const { name: nameParam, pubkeyPrefix: pubkeyPrefixParam } = nodeSearchParams(searchField, search); + const queryKey = useMemo( - () => ["nodes", regionKey, typeFilter, pathsFilter, tracesFilter, search, searchField], - [regionKey, typeFilter, pathsFilter, tracesFilter, search, searchField], + () => ["nodes", regionKey, typeFilter, pathsFilter, tracesFilter, nameParam, pubkeyPrefixParam], + [regionKey, typeFilter, pathsFilter, tracesFilter, nameParam, pubkeyPrefixParam], ); // page the region's nodes 50 at a time (filters stay server-side, in the query key); rows stream @@ -151,8 +162,8 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable getNodesPage(iatas, { cursor, type: typeFilter || undefined, - name: searchField === "name" ? search || undefined : undefined, - pubkeyPrefix: searchField === "pubkey" ? search || undefined : undefined, + name: nameParam, + pubkeyPrefix: pubkeyPrefixParam, supportsMultibytePaths: pathsFilter || undefined, supportsMultibyteTraces: tracesFilter || undefined, }), @@ -189,7 +200,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable search={search} onSearchChange={setSearch} searchField={searchField} - onSearchFieldChange={setSearchField} + onSearchFieldChange={handleSearchFieldChange} typeFilter={typeFilter} onTypeChange={setTypeFilter} pathsFilter={pathsFilter} diff --git a/src/features/nodes/node-search.ts b/src/features/nodes/node-search.ts new file mode 100644 index 00000000..aa2a3c50 --- /dev/null +++ b/src/features/nodes/node-search.ts @@ -0,0 +1,12 @@ +// Maps the Nodes-table search box (one shared input + a field selector) to the server params. +// The Public Key field is a hex prefix; non-hex input is dropped rather than sent, because the +// backend 400s on a non-hex pubkeyPrefix — so a stray character shows the unfiltered list instead +// of erroring the whole table. +export function nodeSearchParams(searchField: string, search: string): { name?: string; pubkeyPrefix?: string } { + const value = search.trim(); + if (searchField === "pubkey") { + const hex = value.toLowerCase(); + return { pubkeyPrefix: /^[0-9a-f]+$/.test(hex) ? hex : undefined }; + } + return { name: value || undefined }; +} diff --git a/tests/features/nodes/node-search.test.ts b/tests/features/nodes/node-search.test.ts new file mode 100644 index 00000000..1f854344 --- /dev/null +++ b/tests/features/nodes/node-search.test.ts @@ -0,0 +1,23 @@ +import { describe, it, expect } from "vitest"; +import { nodeSearchParams } from "../../../src/features/nodes/node-search"; + +describe("nodeSearchParams", () => { + it("maps the name field to the name param", () => { + expect(nodeSearchParams("name", "alpha")).toEqual({ name: "alpha" }); + }); + + it("maps the pubkey field to a lowercased hex prefix", () => { + expect(nodeSearchParams("pubkey", "AB12")).toEqual({ pubkeyPrefix: "ab12" }); + }); + + it("drops a non-hex pubkey prefix instead of firing a request the server 400s", () => { + // names/spaces are non-hex; sending them as pubkeyPrefix would 400 the whole table + expect(nodeSearchParams("pubkey", "alice")).toEqual({ pubkeyPrefix: undefined }); + expect(nodeSearchParams("pubkey", "de ad")).toEqual({ pubkeyPrefix: undefined }); + }); + + it("treats blank input as no filter on either field", () => { + expect(nodeSearchParams("name", " ")).toEqual({ name: undefined }); + expect(nodeSearchParams("pubkey", "")).toEqual({ pubkeyPrefix: undefined }); + }); +}); From 1303e952a7f64cc1bc19c8addd13aabc6201048e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 06:19:19 -0400 Subject: [PATCH 047/163] Add tests for path endpoint, trace, and clock-drift edge cases --- tests/features/map/packet-path.test.ts | 24 +++++++++++++++++++ .../packets/payload-renderers.test.tsx | 10 ++++++++ tests/lib/formatters.test.ts | 5 ++++ 3 files changed, 39 insertions(+) diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 43860ba5..1c783859 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -124,6 +124,30 @@ describe("buildPacketPaths", () => { expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]); }); + it("draws nothing for a TRACE with no resolved route", () => { + // per-observation lines are suppressed for TRACE, so without resolvedRoute there is nothing to draw + const d = detail( + [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], + { header: { payloadType: PayloadType.TRACE, routeType: 1 } } as unknown as Partial, + ); + expect(buildPacketPaths(d)).toEqual([]); + }); + + it("uses the first located candidate for an ambiguous relay hop", () => { + const multi: ResolvedHop = { + confidence: "ambiguous", + nodes: [ + { id: "unlocated", publicKey: "p0" }, // no coords — skipped + { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // first with coords — used + ], + }; + const d = detail([ + obs(1, [multi, hop("relay2", -77, 45)], { observerId: "obs-1", propagationTimeMs: 100 }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["located", "relay2"]); + }); + it("omits observations that resolve to fewer than 2 located hops", () => { const d = detail([ obs(1, [hop("a", -79, 43), hop("x")], { observerId: "obs-1" }), diff --git a/tests/features/packets/payload-renderers.test.tsx b/tests/features/packets/payload-renderers.test.tsx index f759115d..2b695d7b 100644 --- a/tests/features/packets/payload-renderers.test.tsx +++ b/tests/features/packets/payload-renderers.test.tsx @@ -73,6 +73,16 @@ describe("PayloadBreakdown — resolved source/destination endpoints", () => { expect(screen.getByText("BB")).toBeInTheDocument(); }); + it("renders an unresolved (none-confidence) endpoint as a non-clickable resolved block", () => { + // the backend sends resolvedSource/Destination as {confidence:"none", nodes:[]} (not omitted) + // when a 1-byte prefix matches nothing — it must not become a clickable node, but still show the hash + const none: ResolvedHop = { confidence: "none", nodes: [] }; + render(); + expect(screen.queryByRole("button", { name: "AA" })).not.toBeInTheDocument(); + expect(screen.getByText("AA")).toBeInTheDocument(); + expect(screen.getByText("BB")).toBeInTheDocument(); + }); + it("resolves an ANON_REQUEST destination hash to a node block", () => { const onViewNode = vi.fn(); const anon = { type: "ANON_REQUEST", destination: 0xbb, ephemeralPubKey: "cc" }; diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index d94464c7..4e229478 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -119,4 +119,9 @@ describe("formatClockDrift", () => { expect(formatClockDrift(432)).toBe("+7m 12s ahead"); // 7*60 + 12 expect(formatClockDrift(-3670)).toBe("-1h 1m behind"); // 3670 -> 1h 1m }); + + it("renders exact minute/hour boundaries with a zero remainder", () => { + expect(formatClockDrift(60)).toBe("+1m 0s ahead"); + expect(formatClockDrift(3600)).toBe("+1h 0m ahead"); + }); }); From 98ca904ac27c7a6960f50d5162df73e029ae650a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 06:55:24 -0400 Subject: [PATCH 048/163] Refresh stale radio-freq and telemetry comments for current backend --- src/features/nodes/types.ts | 2 +- src/features/observers/types.ts | 2 +- src/features/stats/transforms.ts | 7 ++++--- src/lib/formatters.ts | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 97c552e3..02ca6e1c 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -11,7 +11,7 @@ export interface NodeSummary { name: string | null; lat: number | null; lng: number | null; - radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown + radio?: string; // compact "freq,bw,sf" string, e.g. "915,250,11"; absent when unknown defaultScope?: string; // most recently matched transport scope name, e.g. "#bc" iatas: NodeIATA[]; knownNeighborCount: number; // distinct first-hop neighbors we've resolved for this node diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts index 60c67f1d..ec657135 100644 --- a/src/features/observers/types.ts +++ b/src/features/observers/types.ts @@ -4,7 +4,7 @@ export interface ObserverSummary { observerType?: string; iata: string; status: "online" | "offline"; - radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown + radio?: string; // compact "freq,bw,sf" string, e.g. "915,250,11"; absent when unknown scopes?: string[]; // transport scopes this observer forwards, e.g. ["#bc", "#west"] // epoch ms; not in REST list responses — patched in from WS status events for recency derivation lastStatusAt?: number; diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index 3b9f6410..a6d32560 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -28,9 +28,10 @@ export function formatPreset(preset: string): string { return `${freq} · ${bw}k · SF${sf}`; } -// True if any point carries at least one meaningful (non-null, non-zero) metric. Bots / MQTT bridges -// report telemetry rows that are all zeros (no real radio hardware); those count as "no telemetry" -// so we show an empty state rather than a wall of flat-zero charts. +// True if any point carries at least one meaningful (non-null, non-zero) metric. Stats-less observers +// (bots / MQTT bridges, no real radio hardware) used to report all-zero rows; the backend now drops +// those at ingest, but the non-zero guard stays as a cheap backstop so a stray all-zero row still +// counts as "no telemetry" (empty state) rather than a wall of flat-zero charts. export function hasTelemetry(points: TelemetryPoint[]): boolean { const live = (v: number | null) => v != null && v !== 0; return points.some( diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index e28664a5..22aa4622 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -94,7 +94,7 @@ export function timeAgoMs(epochMs: number): string { return `${Math.floor(hours / 24)}d`; } -// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915.0,250,11"). +// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915,250,11"). // Formats freq/SF/bandwidth like the observer panel ("915 MHz · SF11 · 250 kHz"); the compact // string carries no coding rate, so there's no "CR 4/x" segment. export function formatRadio(radio: string | null | undefined): string | null { From e526c08fa44922dc6683786c239249449bd01668 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 08:47:19 -0400 Subject: [PATCH 049/163] Send multi-select packet filters to the server --- src/api/client.ts | 8 +++--- src/features/packets/types.ts | 8 +++--- src/features/packets/usePacketFilters.ts | 11 ++++---- tests/api/client.test.ts | 26 +++++++++---------- tests/features/packets/PacketList.test.tsx | 6 ++--- .../features/packets/usePacketFilters.test.ts | 23 +++++++++------- 6 files changed, 43 insertions(+), 39 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index 39a9f1fa..794ccce2 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -60,15 +60,15 @@ function iatasParam(iatas?: string[]): string | undefined { export function getPackets( iatas: string[] | undefined, - params?: { cursor?: number; limit?: number; payloadType?: number; routeType?: number; scope?: string }, + params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[] }, ): Promise> { return request("/packets", { iatas: iatasParam(iatas), cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, - payloadType: params?.payloadType, - routeType: params?.routeType, - scope: params?.scope, + payloadTypes: params?.payloadTypes?.length ? params.payloadTypes.join(",") : undefined, + routeTypes: params?.routeTypes?.length ? params.routeTypes.join(",") : undefined, + scopes: params?.scopes?.length ? params.scopes.join(",") : undefined, }); } diff --git a/src/features/packets/types.ts b/src/features/packets/types.ts index e563c105..351f9ac9 100644 --- a/src/features/packets/types.ts +++ b/src/features/packets/types.ts @@ -11,11 +11,11 @@ export interface PacketFilterState { searchField: SearchField; } -// Filters /packets history can apply server-side (each accepts a single value per request) +// Filters /packets history can apply server-side; each accepts multiple comma-separated values. export interface PacketServerFilter { - payloadType?: number; - routeType?: number; - scope?: string; + payloadTypes?: number[]; + routeTypes?: number[]; + scopes?: string[]; } export const EMPTY_FILTERS: PacketFilterState = { diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index 5645a6cb..e1bb284c 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -115,13 +115,14 @@ export function usePacketFilters() { return { filters, setFilter, setSearch, setSearchField, clearFilters }; } -// The /packets endpoint filters by a single payloadType/routeType/scope per request, so a -// dimension only goes server-side when exactly one value is selected; the rest stay client-side. +// The /packets endpoint accepts comma-separated payloadTypes/routeTypes/scopes, so any selected +// dimension goes server-side and pagination pulls the correctly-filtered set from the full history. +// (observers has no server param, so it stays client-side in matchesFilters, as does the live buffer.) export function toServerFilter(filters: PacketFilterState): PacketServerFilter | null { const serverFilter: PacketServerFilter = {}; - if (filters.payloadTypes.length === 1) serverFilter.payloadType = filters.payloadTypes[0]!; - if (filters.routeTypes.length === 1) serverFilter.routeType = filters.routeTypes[0]!; - if (filters.scopes.length === 1) serverFilter.scope = filters.scopes[0]!; + if (filters.payloadTypes.length > 0) serverFilter.payloadTypes = filters.payloadTypes; + if (filters.routeTypes.length > 0) serverFilter.routeTypes = filters.routeTypes; + if (filters.scopes.length > 0) serverFilter.scopes = filters.scopes; return Object.keys(serverFilter).length > 0 ? serverFilter : null; } diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index d950b08f..f59f6763 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -23,16 +23,16 @@ afterEach(() => { }); describe("getPackets", () => { - it("forwards the single-value server filters (routeType 0 survives, scope is encoded)", async () => { + it("forwards plural filters as comma-separated values (routeType 0 survives, scope is encoded)", async () => { const getUrl = mockFetchOnce({ items: [], nextCursor: null, hasMore: false }); - await getPackets(["YOW"], { payloadType: 4, routeType: 0, scope: "#bc" }); + await getPackets(["YOW"], { payloadTypes: [2, 4], routeTypes: [0], scopes: ["#bc", "#west"] }); - const url = getUrl(); - expect(url).toContain("/packets"); - expect(url).toContain("payloadType=4"); - expect(url).toContain("routeType=0"); - expect(url).toContain("scope=%23bc"); + const url = new URL(getUrl()); + expect(url.pathname).toContain("/packets"); + expect(url.searchParams.get("payloadTypes")).toBe("2,4"); + expect(url.searchParams.get("routeTypes")).toBe("0"); // single value 0 survives + expect(url.searchParams.get("scopes")).toBe("#bc,#west"); }); it("omits the filter params when none are given", async () => { @@ -40,12 +40,12 @@ describe("getPackets", () => { await getPackets(["YOW"], { cursor: 100 }); - const url = getUrl(); - expect(url).not.toContain("payloadType="); - expect(url).not.toContain("routeType="); - expect(url).not.toContain("scope="); - expect(url).toContain("cursor=100"); - expect(url).toContain("limit=50"); + const url = new URL(getUrl()); + expect(url.searchParams.has("payloadTypes")).toBe(false); + expect(url.searchParams.has("routeTypes")).toBe(false); + expect(url.searchParams.has("scopes")).toBe(false); + expect(url.searchParams.get("cursor")).toBe("100"); + expect(url.searchParams.get("limit")).toBe("50"); }); }); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 1f40e281..1283b5fd 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -84,13 +84,13 @@ describe("PacketList server filter wiring", () => { it("passes a single selected type to usePackets as the server filter", () => { usePackets.mockClear(); renderAt("/?types=4"); - expect(usePackets).toHaveBeenLastCalledWith(false, { payloadType: 4 }); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] }); }); - it("passes null for multi-select so history stays unfiltered", () => { + it("passes a multi-select filter server-side so history stays filtered", () => { usePackets.mockClear(); renderAt("/?types=2,4"); - expect(usePackets).toHaveBeenLastCalledWith(false, null); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [2, 4] }); }); }); diff --git a/tests/features/packets/usePacketFilters.test.ts b/tests/features/packets/usePacketFilters.test.ts index 43e4593e..88b19a10 100644 --- a/tests/features/packets/usePacketFilters.test.ts +++ b/tests/features/packets/usePacketFilters.test.ts @@ -49,31 +49,34 @@ describe("matchesFilters — scope", () => { }); describe("toServerFilter", () => { - it("returns null when nothing narrows to a single value", () => { + it("returns null when no server-side dimension is selected", () => { expect(toServerFilter(EMPTY_FILTERS)).toBeNull(); - expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [2, 4] as PayloadTypeValue[] })).toBeNull(); }); - it("emits payloadType only for a single selected type", () => { - expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[] })).toEqual({ payloadType: 4 }); + it("pushes a multi-value payload-type selection server-side", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [2, 4] as PayloadTypeValue[] })).toEqual({ payloadTypes: [2, 4] }); }); - it("emits routeType 0 (falsy) for a single selected route", () => { - expect(toServerFilter({ ...EMPTY_FILTERS, routeTypes: [0] as RouteTypeValue[] })).toEqual({ routeType: 0 }); + it("emits payloadTypes for a single selected type", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[] })).toEqual({ payloadTypes: [4] }); }); - it("emits scope for a single selected scope", () => { - expect(toServerFilter({ ...EMPTY_FILTERS, scopes: ["#bc"] })).toEqual({ scope: "#bc" }); + it("emits routeTypes including 0 (falsy) for a selected route", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, routeTypes: [0] as RouteTypeValue[] })).toEqual({ routeTypes: [0] }); }); - it("emits only the single-valued dimensions when combined", () => { + it("emits scopes for selected scopes", () => { + expect(toServerFilter({ ...EMPTY_FILTERS, scopes: ["#bc", "#west"] })).toEqual({ scopes: ["#bc", "#west"] }); + }); + + it("emits every selected dimension together", () => { const filters = { ...EMPTY_FILTERS, payloadTypes: [4] as PayloadTypeValue[], routeTypes: [1, 2] as RouteTypeValue[], scopes: ["#bc"], }; - expect(toServerFilter(filters)).toEqual({ payloadType: 4, scope: "#bc" }); + expect(toServerFilter(filters)).toEqual({ payloadTypes: [4], routeTypes: [1, 2], scopes: ["#bc"] }); }); it("ignores client-only filters (observers, search)", () => { From 45842949d49c83b8a7fdb7ca7d27d40a3933a0f1 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 08:47:19 -0400 Subject: [PATCH 050/163] Group top advertisers and talkers on the Talkers tab --- src/features/stats/MeshTab.tsx | 15 +--------- src/features/stats/TalkersTab.tsx | 46 +++++++++++++++++++++++-------- 2 files changed, 36 insertions(+), 25 deletions(-) diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 3a1bbf9e..47a55859 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useTopAdvertisers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; @@ -37,7 +37,6 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const payload = usePayloadBreakdown(range); const topNodes = useTopNodes(10); const topObservers = useTopObservers(range, 8); - const topAdvertisers = useTopAdvertisers(range, 10); const radioPresets = useRadioPresets(); const scopes = useScopes(); const nodeTypes = useNodeTypes(); @@ -56,17 +55,6 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { ); const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); - const advertiserRows = useMemo( - () => - (topAdvertisers.data ?? []).map((a) => ({ - name: a.nodeName ?? a.nodeId.slice(0, 8), - value: a.advertCount, - color: nodeTypeColor(a.nodeTypeName, colors), - })), - [topAdvertisers.data, colors], - ); - const advertisersOption = useMemo(() => leaderboardOption(advertiserRows, colors), [advertiserRows, colors]); - const payloadItems = useMemo( () => (payload.data ?? []) @@ -152,7 +140,6 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isError={payload.isError} isEmpty={payloadItems.length === 0} /> - Top advertisers · {range}} height={208} option={advertisersOption} isLoading={topAdvertisers.isLoading} isError={topAdvertisers.isError} isEmpty={advertiserRows.length === 0} /> {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index e427ecbf..5da1dc2f 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; -import { useChartColors } from "./chartTheme"; -import { useTopTalkers } from "./useStats"; +import { useChartColors, nodeTypeColor } from "./chartTheme"; +import { useTopAdvertisers, useTopTalkers } from "./useStats"; import { leaderboardOption } from "./chartOptions"; import { ChartCard } from "./cards"; import type { StatsRange } from "./types"; @@ -9,30 +9,54 @@ interface TalkersTabProps { range: StatsRange; } -// Top talkers by decrypted channel-message count. Grouped by sender display-name (see TopTalker), -// hence the "by name" caption — its own tab so the leaderboard can breathe and later grow. +// grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing +function leaderboardHeight(count: number) { + return Math.max(260, count * 34 + 24); +} + +// The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers are +// coloured by node type; talkers are grouped by sender display-name (see TopTalker), hence "by name". export function TalkersTab({ range }: TalkersTabProps) { const colors = useChartColors(); + const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const rows = useMemo( + const advertiserRows = useMemo( + () => + (topAdvertisers.data ?? []).map((a) => ({ + name: a.nodeName ?? a.nodeId.slice(0, 8), + value: a.advertCount, + color: nodeTypeColor(a.nodeTypeName, colors), + })), + [topAdvertisers.data, colors], + ); + const advertisersOption = useMemo(() => leaderboardOption(advertiserRows, colors), [advertiserRows, colors]); + + const talkerRows = useMemo( () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), [topTalkers.data, colors], ); - const option = useMemo(() => leaderboardOption(rows, colors), [rows, colors]); - // grow with the roster so bars stay readable; a floor keeps the loading/empty state from collapsing - const height = Math.max(260, rows.length * 34 + 24); + const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); return (
+ Top advertisers · {range}} + right={by adverts} + height={leaderboardHeight(advertiserRows.length)} + option={advertisersOption} + isLoading={topAdvertisers.isLoading} + isError={topAdvertisers.isError} + isEmpty={advertiserRows.length === 0} + /> Top talkers · {range}} right={by name} - height={height} - option={option} + height={leaderboardHeight(talkerRows.length)} + option={talkersOption} isLoading={topTalkers.isLoading} isError={topTalkers.isError} - isEmpty={rows.length === 0} + isEmpty={talkerRows.length === 0} />
); From 34bb17142938b1592a06a4f8b17189092bdb606f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 08:54:23 -0400 Subject: [PATCH 051/163] Lay out Talkers side by side and label advertiser node types --- src/features/stats/TalkersTab.tsx | 35 +++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 5da1dc2f..3f50d4e1 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -3,7 +3,9 @@ import { useChartColors, nodeTypeColor } from "./chartTheme"; import { useTopAdvertisers, useTopTalkers } from "./useStats"; import { leaderboardOption } from "./chartOptions"; import { ChartCard } from "./cards"; -import type { StatsRange } from "./types"; +import { NODE_TYPES } from "../../lib/node-types"; +import type { ChartColors } from "./chartTheme"; +import type { TopAdvertiser, StatsRange } from "./types"; interface TalkersTabProps { range: StatsRange; @@ -14,8 +16,21 @@ function leaderboardHeight(count: number) { return Math.max(260, count * 34 + 24); } +// Names the node-type colours the advertiser bars already use, so a bar's colour is legible as +// "repeater" or "companion". Only the types actually present are listed; unknowns fall under "Other". +function advertiserLegend(rows: TopAdvertiser[], c: ChartColors) { + const present = new Set(rows.map((r) => r.nodeTypeName)); + const known = NODE_TYPES.filter((t) => present.has(t.name)).map((t) => ({ + label: t.label, + color: nodeTypeColor(t.name, c), + })); + const knownNames = new Set(NODE_TYPES.map((t) => t.name)); + const hasOther = [...present].some((n) => !knownNames.has(n)); + return hasOther ? [...known, { label: "Other", color: c.primaryDim }] : known; +} + // The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers are -// coloured by node type; talkers are grouped by sender display-name (see TopTalker), hence "by name". +// coloured by node type (see legend); talkers are grouped by sender display-name, hence "by name". export function TalkersTab({ range }: TalkersTabProps) { const colors = useChartColors(); const topAdvertisers = useTopAdvertisers(range, 20); @@ -31,6 +46,7 @@ export function TalkersTab({ range }: TalkersTabProps) { [topAdvertisers.data, colors], ); const advertisersOption = useMemo(() => leaderboardOption(advertiserRows, colors), [advertiserRows, colors]); + const legend = useMemo(() => advertiserLegend(topAdvertisers.data ?? [], colors), [topAdvertisers.data, colors]); const talkerRows = useMemo( () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), @@ -39,10 +55,21 @@ export function TalkersTab({ range }: TalkersTabProps) { const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); return ( -
+
Top advertisers · {range}} - right={by adverts} + right={ + legend.length > 0 ? ( +
+ {legend.map((t) => ( + + + {t.label} + + ))} +
+ ) : undefined + } height={leaderboardHeight(advertiserRows.length)} option={advertisersOption} isLoading={topAdvertisers.isLoading} From f1a69fa1210c2509056c544edb3640b2f8ff73de Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 09:11:32 -0400 Subject: [PATCH 052/163] Mark each top advertiser with its IATA --- src/features/stats/TalkersTab.tsx | 1 + src/features/stats/chartOptions.ts | 25 ++++++++++++++++++---- tests/features/stats/chart-options.test.ts | 20 +++++++++++++++++ 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 3f50d4e1..c01516cd 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -42,6 +42,7 @@ export function TalkersTab({ range }: TalkersTabProps) { name: a.nodeName ?? a.nodeId.slice(0, 8), value: a.advertCount, color: nodeTypeColor(a.nodeTypeName, colors), + iata: a.iata, })), [topAdvertisers.data, colors], ); diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 8cd36b6c..9b0e8406 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -84,14 +84,15 @@ export function observationsAreaOption( } export function leaderboardOption( - rows: { name: string; value: number; color: string }[], + rows: { name: string; value: number; color: string; iata?: string }[], c: ChartColors, gridLeft = 116, // widen for longer category labels (e.g. radio presets) ): EChartsOption { + const hasIata = rows.some((r) => Boolean(r.iata)); // reserve room for the end-of-bar chip only when needed return { animation: false, backgroundColor: "transparent", - grid: { left: gridLeft, right: 56, top: 6, bottom: 6 }, + grid: { left: gridLeft, right: hasIata ? 96 : 56, top: 6, bottom: 6 }, tooltip: { trigger: "item", ...tooltipStyle(c) }, xAxis: { type: "value", axisLabel: { show: false }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false } }, yAxis: { @@ -117,14 +118,30 @@ export function leaderboardOption( type: "bar", barMaxWidth: 22, barCategoryGap: "42%", - data: rows.map((r) => ({ value: r.value, itemStyle: { color: r.color, borderRadius: [0, 4, 4, 0] } })), + data: rows.map((r) => ({ value: r.value, iata: r.iata, itemStyle: { color: r.color, borderRadius: [0, 4, 4, 0] } })), label: { show: true, position: "right", color: c.textBright, fontFamily: MONO, fontSize: 11, - formatter: (p: { value: number }) => p.value.toLocaleString(), + // count, plus an IataChip-style location marker when the row carries one + formatter: (p: { value: number; data?: { iata?: string } }) => { + const v = p.value.toLocaleString(); + return p.data?.iata ? `{v|${v}} {iata|${p.data.iata}}` : v; + }, + rich: { + v: { color: c.textBright, fontFamily: MONO, fontSize: 11 }, + iata: { + color: c.primary, + backgroundColor: withAlpha(c.primary, 0.1), + fontFamily: MONO, + fontWeight: "bold", + fontSize: 10, + padding: [2, 4], + borderRadius: 3, + }, + }, }, }, ], diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index a8d189ed..6a4784dc 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -108,6 +108,26 @@ describe("leaderboardOption", () => { expect(opt.yAxis.axisLabel.width).toBeLessThanOrEqual(120 - 10); expect(opt.yAxis.axisLabel.margin).toBe(110); }); + + it("keeps a plain count label and the tight right gutter when rows carry no IATA", () => { + const rows = [{ name: "node-a", value: 12, color: "#abc" }]; + const opt = leaderboardOption(rows, colors) as Record; + expect(opt.series[0].label.formatter({ value: 12, data: {} })).toBe("12"); + expect(opt.grid.right).toBe(56); + }); + + it("stamps an IATA chip beside the count when rows carry one, widening the right gutter", () => { + const rows = [{ name: "node-a", value: 12, color: "#abc", iata: "YOW" }]; + const opt = leaderboardOption(rows, colors) as Record; + // the code rides on the data item so the label can read it back + expect(opt.series[0].data[0].iata).toBe("YOW"); + const label = opt.series[0].label; + const out = label.formatter({ value: 12, data: { iata: "YOW" } }); + expect(out).toContain("12"); + expect(out).toContain("YOW"); + expect(label.rich.iata).toBeDefined(); // chip style lives in the rich block + expect(opt.grid.right).toBeGreaterThan(56); // room for the chip at the bar end + }); }); const point = (t: number, p: Partial): TelemetryPoint => ({ From 7a2d74b7cac6a9cadf8379d4c5ff23beb6f973ff Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 09:47:47 -0400 Subject: [PATCH 053/163] Pin overview sparklines to a fixed 24h window The Observations and Active observers KPI cards show a fixed 24h snapshot (from /stats/overview), but their sparklines were fed by the range-driven observations series. Switching to 7d/30d reshaped the sparklines even though the numbers beside them stayed at 24h, since aggregateByHour drops empty hours and slice(-24) then spans a different set of hours. Give the sparklines their own dedicated 24h observations series so the whole top row is a consistent 24h snapshot; the range selector now only drives the charts below. React Query dedupes the extra call when the selector is already on 24h. --- src/features/stats/MeshTab.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 47a55859..f43a0206 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -34,6 +34,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { useLiveOverview(wsManager); const overview = useStatsOverview(); const observations = useStatsObservations(range); + // top-row KPIs are a fixed 24h snapshot, so their sparklines use a dedicated + // 24h series rather than the range-driven one (deduped by query key when range is 24h) + const overviewObs = useStatsObservations("24h"); const payload = usePayloadBreakdown(range); const topNodes = useTopNodes(10); const topObservers = useTopObservers(range, 8); @@ -102,8 +105,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [scopes.data], ); - const obsSpark = useMemo(() => obs.slice(-24).map((p) => p.observationCount), [obs]); - const observerSpark = useMemo(() => obs.slice(-24).map((p) => p.activeObservers), [obs]); + const kpiObs = useMemo(() => aggregateByHour(overviewObs.data ?? []), [overviewObs.data]); + const obsSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.observationCount), [kpiObs]); + const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]); const ov = overview.data; const kpiLoading = overview.isLoading; From e6a714aed90870b1aa0c92355bf94cc94c69ea8b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 22:19:24 -0400 Subject: [PATCH 054/163] Split top advertisers into flood and direct adverts Surface each advertiser's flood vs direct advert counts with a per-day rate for the selected window, as a sortable table on the Talkers tab. --- src/features/stats/TalkersTab.tsx | 101 +++++++++++++++--------------- src/features/stats/types.ts | 4 ++ src/lib/formatters.ts | 10 +++ tests/lib/formatters.test.ts | 30 +++++++++ 4 files changed, 94 insertions(+), 51 deletions(-) diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index c01516cd..0ef75873 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -1,10 +1,13 @@ import { useMemo } from "react"; -import { useChartColors, nodeTypeColor } from "./chartTheme"; +import { useChartColors } from "./chartTheme"; import { useTopAdvertisers, useTopTalkers } from "./useStats"; import { leaderboardOption } from "./chartOptions"; -import { ChartCard } from "./cards"; -import { NODE_TYPES } from "../../lib/node-types"; -import type { ChartColors } from "./chartTheme"; +import { Card, ChartCard } from "./cards"; +import { DataTable, type Column } from "../../components/DataTable"; +import { Badge } from "../../components/Badge"; +import { IataChip } from "../../components/IataChip"; +import { formatCount, formatRatePerDay } from "../../lib/formatters"; +import { RANGE_MS } from "./types"; import type { TopAdvertiser, StatsRange } from "./types"; interface TalkersTabProps { @@ -16,38 +19,41 @@ function leaderboardHeight(count: number) { return Math.max(260, count * 34 + 24); } -// Names the node-type colours the advertiser bars already use, so a bar's colour is legible as -// "repeater" or "companion". Only the types actually present are listed; unknowns fall under "Other". -function advertiserLegend(rows: TopAdvertiser[], c: ChartColors) { - const present = new Set(rows.map((r) => r.nodeTypeName)); - const known = NODE_TYPES.filter((t) => present.has(t.name)).map((t) => ({ - label: t.label, - color: nodeTypeColor(t.name, c), - })); - const knownNames = new Set(NODE_TYPES.map((t) => t.name)); - const hasOther = [...present].some((n) => !knownNames.has(n)); - return hasOther ? [...known, { label: "Other", color: c.primaryDim }] : known; -} - -// The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers are -// coloured by node type (see legend); talkers are grouped by sender display-name, hence "by name". +// The "noisy nodes, politely" tab: who's loudest by adverts and by channel chatter. Advertisers list +// their flood/direct advert split with a per-day rate; talkers are grouped by sender display-name. export function TalkersTab({ range }: TalkersTabProps) { const colors = useChartColors(); const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const advertiserRows = useMemo( - () => - (topAdvertisers.data ?? []).map((a) => ({ - name: a.nodeName ?? a.nodeId.slice(0, 8), - value: a.advertCount, - color: nodeTypeColor(a.nodeTypeName, colors), - iata: a.iata, - })), - [topAdvertisers.data, colors], - ); - const advertisersOption = useMemo(() => leaderboardOption(advertiserRows, colors), [advertiserRows, colors]); - const legend = useMemo(() => advertiserLegend(topAdvertisers.data ?? [], colors), [topAdvertisers.data, colors]); + const advertisers = topAdvertisers.data ?? []; + + const advertiserColumns = useMemo[]>(() => { + const windowMs = RANGE_MS[range]; + // count over the compacted total, then the per-day rate for the same window in muted text + const split = (count: number) => ( + + {formatCount(count)} {formatRatePerDay(count, windowMs)} + + ); + return [ + { + header: "Node", + cell: (a) => ( +
+ + {a.nodeName ?? a.nodeId.slice(0, 8)} + + {a.nodeTypeName} + {a.iata} +
+ ), + sortValue: (a) => a.nodeName ?? a.nodeId, + }, + { header: "Flood", className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount }, + { header: "Direct", className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount }, + ]; + }, [range]); const talkerRows = useMemo( () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), @@ -57,26 +63,19 @@ export function TalkersTab({ range }: TalkersTabProps) { return (
- Top advertisers · {range}} - right={ - legend.length > 0 ? ( -
- {legend.map((t) => ( - - - {t.label} - - ))} -
- ) : undefined - } - height={leaderboardHeight(advertiserRows.length)} - option={advertisersOption} - isLoading={topAdvertisers.isLoading} - isError={topAdvertisers.isError} - isEmpty={advertiserRows.length === 0} - /> + Top advertisers · {range}} right={flood · direct}> +
+ a.nodeId} + selectedKey={null} + onSelect={() => {}} + isLoading={topAdvertisers.isLoading} + emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"} + /> +
+
Top talkers · {range}} right={by name} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 6bf24fc4..4cd137a4 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -47,6 +47,10 @@ export interface TopAdvertiser { nodeTypeName: string; iata: string; advertCount: number; + // advertCount split by route: flood = route type 0/1 (broadcast, no path), direct = 2/3 (routed). + // floodAdvertCount + directAdvertCount === advertCount. + floodAdvertCount: number; + directAdvertCount: number; lastHeard: number; // epoch ms } diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 22aa4622..3c1b0b61 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -83,6 +83,16 @@ export function formatCount(n: number | null | undefined): string { return fmt(1_000_000_000, "B"); } +// Average count per day over a window, e.g. 340 adverts across 7d -> "49/d". Sub-ten rates keep one +// decimal so a handful of events over a long window doesn't round away to "0/d". +export function formatRatePerDay(count: number | null | undefined, windowMs: number): string { + if (count == null || !Number.isFinite(count)) return "—"; + const days = windowMs / 86_400_000; + const rate = days > 0 ? count / days : 0; + const shown = rate >= 10 ? formatCount(Math.round(rate)) : String(Math.round(rate * 10) / 10); + return `${shown}/d`; +} + // clamp negative values from clock skew export function timeAgoMs(epochMs: number): string { const seconds = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index 4e229478..db25110e 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -8,8 +8,11 @@ import { formatPropagation, formatCount, formatClockDrift, + formatRatePerDay, } from "../../src/lib/formatters"; +const DAY_MS = 86_400_000; + describe("formatHex", () => { it("truncates to 8 chars uppercase", () => { expect(formatHex("9e9b7d6a91cab445")).toBe("9E9B7D6A"); @@ -125,3 +128,30 @@ describe("formatClockDrift", () => { expect(formatClockDrift(3600)).toBe("+1h 0m ahead"); }); }); + +describe("formatRatePerDay", () => { + it("equals the compacted count over a one-day window", () => { + expect(formatRatePerDay(1240, DAY_MS)).toBe("1.2k/d"); + expect(formatRatePerDay(340, DAY_MS)).toBe("340/d"); + }); + + it("divides the count by the window in days and rounds to a whole rate", () => { + expect(formatRatePerDay(1240, 7 * DAY_MS)).toBe("177/d"); // 177.14 -> 177 + expect(formatRatePerDay(340, 7 * DAY_MS)).toBe("49/d"); // 48.57 -> 49 + }); + + it("keeps one decimal for sub-ten rates so small counts don't vanish", () => { + expect(formatRatePerDay(12, 30 * DAY_MS)).toBe("0.4/d"); // 0.4/day + expect(formatRatePerDay(138, 30 * DAY_MS)).toBe("4.6/d"); // 4.6/day + }); + + it("returns a zero rate for a zero count or a zero window", () => { + expect(formatRatePerDay(0, 7 * DAY_MS)).toBe("0/d"); + expect(formatRatePerDay(5, 0)).toBe("0/d"); + }); + + it("shows a dash for a missing count, matching formatCount", () => { + expect(formatRatePerDay(null, 7 * DAY_MS)).toBe("—"); + expect(formatRatePerDay(undefined, 7 * DAY_MS)).toBe("—"); + }); +}); From 8baa5627949627096e57a54116c2a2b30a627c64 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 22:20:49 -0400 Subject: [PATCH 055/163] Add a Clock Drift analytics tab List repeaters and room servers whose advert-derived clock has drifted past the server threshold, worst first, in a sortable table. It's each node's latest reading, so there's no time range to pick. --- src/api/client.ts | 7 +++ src/features/stats/ClockDriftTab.tsx | 71 +++++++++++++++++++++++++++ src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 14 +++++- src/features/stats/types.ts | 16 +++++- src/features/stats/useStats.ts | 11 +++++ tests/api/client.test.ts | 13 ++++- 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 src/features/stats/ClockDriftTab.tsx diff --git a/src/api/client.ts b/src/api/client.ts index 794ccce2..2518b545 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -15,6 +15,7 @@ import type { ScopeStats, ObserverTelemetry, NodeTypeCount, + ClockDriftEntry, } from "../features/stats/types"; // typed fetch wrapper with query params @@ -302,6 +303,12 @@ export function getStatsNodeTypes(iatas?: string[]): Promise { return request("/stats/node-types", { iatas: iatasParam(iatas) }); } +// Repeaters/room servers whose clock has drifted past the server threshold, worst-first. Not +// time-windowed and top-N only (no cursor), so callers pass a generous limit and page client-side. +export function getClockDrift(iatas?: string[], limit = 100): Promise { + return request("/stats/clock-drift", { iatas: iatasParam(iatas), limit }); +} + // renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes // aggregate (packet/observer/node counts), reported globally regardless of the active region. export function getStatsScopes(): Promise { diff --git a/src/features/stats/ClockDriftTab.tsx b/src/features/stats/ClockDriftTab.tsx new file mode 100644 index 00000000..7cc1be86 --- /dev/null +++ b/src/features/stats/ClockDriftTab.tsx @@ -0,0 +1,71 @@ +import { useClockDrift } from "./useStats"; +import { DataTable, type Column } from "../../components/DataTable"; +import { Badge } from "../../components/Badge"; +import { IataChip } from "../../components/IataChip"; +import { Timestamp } from "../../components/Timestamp"; +import { formatClockDrift } from "../../lib/formatters"; +import type { ClockDriftEntry } from "./types"; + +// every row is already past the drift threshold; flag the worst (>= 1h off) more urgently +function driftClass(seconds: number) { + return Math.abs(seconds) >= 3600 ? "text-danger" : "text-warn"; +} + +const columns: Column[] = [ + { + header: "Node", + cell: (e) => ( +
+ + {e.nodeName ?? e.nodeId.slice(0, 8)} + + {e.nodeTypeName} +
+ ), + sortValue: (e) => e.nodeName ?? e.nodeId, + }, + { + header: "Drift", + className: "tabular-nums", + cell: (e) => {formatClockDrift(e.clockDriftSeconds)}, + sortValue: (e) => Math.abs(e.clockDriftSeconds), + }, + { + header: "Checked", + cell: (e) => , + sortValue: (e) => e.clockCheckedAt, + }, + { + header: "IATAs", + cell: (e) => ( +
+ {(e.iatas ?? []).map((i) => ( + {i.iata} + ))} +
+ ), + }, +]; + +// Repeaters/room servers whose advert-derived clock has drifted past the server threshold, worst +// first. Not time-windowed (each row is the node's latest reading), so there's no range selector. +export function ClockDriftTab() { + const clockDrift = useClockDrift(); + return ( +
+
+ Repeaters & room servers out of sync · worst first +
+ e.nodeId} + selectedKey={null} + onSelect={() => {}} + isLoading={clockDrift.isLoading} + emptyLabel={clockDrift.isError ? "Failed to load" : "No repeaters out of sync"} + defaultSort={{ header: "Drift", direction: "desc" }} + /> +
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index b9c689b1..3159c2e9 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -4,11 +4,12 @@ import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; import { TalkersTab } from "./TalkersTab"; +import { ClockDriftTab } from "./ClockDriftTab"; import { ObserverTab } from "./ObserverTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "talkers", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -54,6 +55,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
{tab === "mesh" && } {tab === "talkers" && } + {tab === "clockdrift" && } {tab === "observer" && ( )} diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 9917d311..b4bc6cad 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -32,6 +32,15 @@ function TalkersIcon() { ); } +function ClockDriftIcon() { + return ( + + + + + ); +} + function GraphIcon() { return ( @@ -48,6 +57,7 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "talkers", label: "Talkers", icon: }, + { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; @@ -93,8 +103,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props />
)} - {/* the graph is topology, not time-series — no range to pick */} - {tab !== "graph" && ( + {/* graph is topology and clock-drift is each node's latest reading — neither is time-windowed */} + {tab !== "graph" && tab !== "clockdrift" && ( = { diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 9f61c0bc..1c5f72cb 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -11,6 +11,7 @@ import { getRadioPresets, getStatsScopes, getStatsNodeTypes, + getClockDrift, } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; @@ -110,6 +111,16 @@ export function useNodeTypes() { }); } +// clock drift reflects each node's latest measured drift, not a windowed aggregate, so region-only +export function useClockDrift(limit = 100) { + const { iatas, regionKey } = useRegion(); + return useQuery({ + queryKey: ["stats-clock-drift", regionKey, limit], + queryFn: () => getClockDrift(iatas, limit), + ...common, + }); +} + // scopes are reported globally by the backend (no region filter), so the key is region-independent export function useScopes() { return useQuery({ diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index f59f6763..fa0a8601 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift } from "../../src/api/client"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -424,4 +424,15 @@ describe("stats endpoints", () => { expect(url.pathname).toContain("/stats/node-types"); expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); }); + + it("hits /stats/clock-drift with iatas/limit", async () => { + const getUrl = mockFetchOnce([]); + + await getClockDrift(["YOW", "YYZ"], 100); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/stats/clock-drift"); + expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); + expect(url.searchParams.get("limit")).toBe("100"); + }); }); From 464e701f767e75eba68e63eef9ec4aa6fc8d4efe Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Fri, 24 Jul 2026 22:21:12 -0400 Subject: [PATCH 056/163] Add a toggleable IATA border layer to the map Draw each active IATA's GeoJSON region border as an outline over a faint fill beneath the node markers, behind a Map Settings toggle that's off by default and shareable via a URL param. IATAs with no border answer 204 and are skipped. --- src/api/client.ts | 14 +++ src/features/map/MapSettingsPanel.tsx | 31 +++++++ src/features/map/MapView.tsx | 25 +++++- src/features/map/map-url.ts | 6 ++ src/features/map/types.ts | 6 ++ src/features/map/useMapBorders.ts | 92 ++++++++++++++++++++ src/features/map/useMapBordersData.ts | 40 +++++++++ tests/api/client.test.ts | 52 ++++++++++- tests/features/map/map-url.test.ts | 12 ++- tests/features/map/useMapBordersData.test.ts | 31 +++++++ 10 files changed, 305 insertions(+), 4 deletions(-) create mode 100644 src/features/map/useMapBorders.ts create mode 100644 src/features/map/useMapBordersData.ts create mode 100644 tests/features/map/useMapBordersData.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index 2518b545..3e29476d 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -17,6 +17,9 @@ import type { NodeTypeCount, ClockDriftEntry, } from "../features/stats/types"; +import type { Feature, Polygon, MultiPolygon } from "geojson"; + +export type IataBorder = Feature; // typed fetch wrapper with query params @@ -81,6 +84,17 @@ export function getIatas(): Promise { return request("/iatas"); } +// An IATA's GeoJSON border, or null when none is configured. Can't use request(): the endpoint +// answers 204 (empty body) or a literal `null` for "no border", and request() always parses JSON. +export async function getIataBorder(iata: string): Promise { + const url = new URL(`${API_BASE}/iatas/${iata}/border`, window.location.origin); + const res = await fetch(url.toString()); + if (res.status === 204) return null; + if (!res.ok) throw new ApiError(res.status, "unknown", res.statusText); + const body = await res.json(); + return (body ?? null) as IataBorder | null; +} + export function getRegions(): Promise { return request("/regions"); } diff --git a/src/features/map/MapSettingsPanel.tsx b/src/features/map/MapSettingsPanel.tsx index c8d208ae..8f867446 100644 --- a/src/features/map/MapSettingsPanel.tsx +++ b/src/features/map/MapSettingsPanel.tsx @@ -19,6 +19,23 @@ const NEIGHBOR_OPTIONS = [ { value: "selected", label: "Selected" }, { value: "off", label: "Off" }, ]; +const BORDER_OPTIONS = [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, +]; + +// Swatch matching the border layer paint (secondary line over a faint fill), so the legend tracks the theme. +function BorderLegend() { + return ( +
+ + IATA region outline +
+ ); +} // Legend for a selected node's coloured edges. Gradient stops mirror the map paint's log anchors // (red ~1, yellow ~20 at 60%, green ~150+); palette vars keep it in step with the active theme. @@ -49,6 +66,8 @@ interface MapSettingsPanelProps { onClusteredChange: (c: boolean) => void; neighborLines: NeighborLinesMode; onNeighborLinesChange: (mode: NeighborLinesMode) => void; + borders: boolean; + onBordersChange: (on: boolean) => void; // builds deep-link params for the current view, evaluated at copy time (reads the live camera) buildShareParams: () => Record; } @@ -62,6 +81,8 @@ export function MapSettingsPanel({ onClusteredChange, neighborLines, onNeighborLinesChange, + borders, + onBordersChange, buildShareParams, }: MapSettingsPanelProps) { const isMobile = useIsMobile(); @@ -134,6 +155,16 @@ export function MapSettingsPanel({ /> {neighborLines === "selected" && } +
+ onBordersChange(v === "on")} + className="w-full" + /> + {borders && } +
urlView.flow ?? false); + // IATA region borders overlay, off by default; seeded URL -> localStorage like the other toggles + const [borders, setBorders] = useState(() => urlView.borders ?? localStorage.getItem(MAP_BORDERS_STORAGE_KEY) === "on"); + const handleBordersChange = useCallback((on: boolean) => { + setBorders(on); + localStorage.setItem(MAP_BORDERS_STORAGE_KEY, on ? "on" : "off"); + }, []); + // A deep-link camera opens the map here and suppresses the initial region fit (see useMapLibre). const initialCamera = useMemo( () => (urlView.center ? { center: urlView.center, zoom: urlView.zoom ?? DEFAULT_ZOOM } : undefined), @@ -167,6 +176,14 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp return chosen.length > 0 ? chosen.map((i) => [i.lon!, i.lat!]) : null; }, [iatas, selectedIatas]); + // Borders to draw: the selected region's IATAs, or every IATA for "All" (most have none configured, + // which resolves to a 204 and is dropped). Only fetched while the layer is toggled on. + const borderIatas = useMemo(() => { + const all = (iatas ?? []).map((i) => i.iata); + return selectedIatas && selectedIatas.length > 0 ? all.filter((c) => selectedIatas.includes(c)) : all; + }, [iatas, selectedIatas]); + const borderData = useMapBordersData(borderIatas, borders); + const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome @@ -183,12 +200,14 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp neighborLines, styleId, flow: packetFlow, + borders, }; return { tab: "Map", ...buildMapParams(snapshot) }; - }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow]); + }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]); useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); + useMapBorders(mapRef, isReady, borderData, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); return ( @@ -206,6 +225,8 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp onClusteredChange={handleClusteredChange} neighborLines={neighborLines} onNeighborLinesChange={handleNeighborLinesChange} + borders={borders} + onBordersChange={handleBordersChange} buildShareParams={buildShareParams} /> setPacketFlow((v) => !v)} /> diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts index d294318f..db55f5e7 100644 --- a/src/features/map/map-url.ts +++ b/src/features/map/map-url.ts @@ -13,6 +13,7 @@ export interface ParsedMapView { neighborLines?: NeighborLinesMode; styleId?: string; flow?: boolean; + borders?: boolean; } // The live map state a copy-link snapshot is built from (every field concrete). @@ -24,6 +25,7 @@ export interface MapViewSnapshot { neighborLines: NeighborLinesMode; styleId: string; flow: boolean; + borders: boolean; } const NEIGHBOR_MODES: NeighborLinesMode[] = ["on", "selected", "off"]; @@ -78,6 +80,9 @@ export function parseMapView(params: URLSearchParams): ParsedMapView { const flow = parseBool(params.get("flow")); if (flow !== undefined) view.flow = flow; + const borders = parseBool(params.get("borders")); + if (borders !== undefined) view.borders = borders; + return view; } @@ -100,5 +105,6 @@ export function buildMapParams(view: MapViewSnapshot): Record, + isReady: boolean, + data: BorderFeatureCollection, + themeKey: string, +) { + const dataRef = useRef(data); + useEffect(() => { + dataRef.current = data; + }, [data]); + + // build source + fill + line, and keep the colour in step with the palette + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + + const color = paletteVar("--palette-secondary", "#A78BFA"); + const beforeId = map.getLayer(NODES_CLUSTER_LAYER_ID) ? NODES_CLUSTER_LAYER_ID : undefined; + + if (!map.getSource(IATA_BORDERS_SOURCE_ID)) { + map.addSource(IATA_BORDERS_SOURCE_ID, { type: "geojson", data: dataRef.current }); + } + // fill first so the outline sits on top of it; both go beneath the node markers + if (!map.getLayer(IATA_BORDERS_FILL_LAYER_ID)) { + map.addLayer( + { + id: IATA_BORDERS_FILL_LAYER_ID, + type: "fill", + source: IATA_BORDERS_SOURCE_ID, + paint: { "fill-color": color, "fill-opacity": 0.08 }, + } as FillLayerSpecification, + beforeId, + ); + } + if (!map.getLayer(IATA_BORDERS_LINE_LAYER_ID)) { + map.addLayer( + { + id: IATA_BORDERS_LINE_LAYER_ID, + type: "line", + source: IATA_BORDERS_SOURCE_ID, + layout: { "line-cap": "round", "line-join": "round" }, + paint: { "line-color": color, "line-width": 1.5, "line-opacity": 0.8 }, + } as LineLayerSpecification, + beforeId, + ); + } + map.setPaintProperty(IATA_BORDERS_FILL_LAYER_ID, "fill-color", color); + map.setPaintProperty(IATA_BORDERS_LINE_LAYER_ID, "line-color", color); + (map.getSource(IATA_BORDERS_SOURCE_ID) as GeoJSONSource).setData(dataRef.current); + }, [mapRef, isReady, themeKey]); + + // push new border data as the toggle / region changes + useEffect(() => { + const map = mapRef.current; + if (!map || !isReady) return; + const src = map.getSource(IATA_BORDERS_SOURCE_ID) as GeoJSONSource | undefined; + if (src) src.setData(data); + }, [mapRef, isReady, data]); + + // remove layers (before the source) on unmount; runs before useMapLibre tears the map down + useEffect(() => { + const map = mapRef.current; + return () => { + if (!map) return; + try { + if (map.getLayer(IATA_BORDERS_LINE_LAYER_ID)) map.removeLayer(IATA_BORDERS_LINE_LAYER_ID); + if (map.getLayer(IATA_BORDERS_FILL_LAYER_ID)) map.removeLayer(IATA_BORDERS_FILL_LAYER_ID); + if (map.getSource(IATA_BORDERS_SOURCE_ID)) map.removeSource(IATA_BORDERS_SOURCE_ID); + } catch { + // map may already be torn down + } + }; + }, [mapRef]); +} diff --git a/src/features/map/useMapBordersData.ts b/src/features/map/useMapBordersData.ts new file mode 100644 index 00000000..f89f326b --- /dev/null +++ b/src/features/map/useMapBordersData.ts @@ -0,0 +1,40 @@ +import { useMemo } from "react"; +import { useQueries } from "@tanstack/react-query"; +import type { Feature, FeatureCollection, Polygon, MultiPolygon } from "geojson"; +import { getIataBorder, type IataBorder } from "../../api/client"; + +export type BorderProps = { iata: string; [key: string]: unknown }; +export type BorderFeatureCollection = FeatureCollection; + +// Merge each IATA's border into one collection, dropping the ones with no border and stamping the +// IATA code onto every feature so the layer can style/label per region. +export function mergeBorders(entries: { iata: string; border: IataBorder | null }[]): BorderFeatureCollection { + const features = entries.flatMap((e) => + e.border + ? [{ ...e.border, properties: { ...(e.border.properties ?? {}), iata: e.iata } } as Feature] + : [], + ); + return { type: "FeatureCollection", features }; +} + +// Fetch the border for each active IATA (only while `enabled`), then merge into one collection. +// Borders are static, so each is cached indefinitely and most IATAs simply have none (204 -> null). +export function useMapBordersData(iataCodes: string[], enabled: boolean): BorderFeatureCollection { + const results = useQueries({ + queries: iataCodes.map((iata) => ({ + queryKey: ["iata-border", iata], + queryFn: () => getIataBorder(iata), + enabled, + staleTime: Infinity, + })), + }); + + // useQueries returns a fresh array each render; a border is immutable once fetched, so a signature + // of which IATAs have resolved one is enough to keep the collection reference stable between renders. + const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}`).join("|"); + return useMemo( + () => mergeBorders(iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null }))), + // eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures iataCodes + which borders loaded + [sig], + ); +} diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index fa0a8601..207a36dd 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder } from "../../src/api/client"; +import type { Feature, Polygon } from "geojson"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; @@ -436,3 +437,52 @@ describe("stats endpoints", () => { expect(url.searchParams.get("limit")).toBe("100"); }); }); + +describe("getIataBorder", () => { + // this endpoint can 204 (empty body) or send a literal `null`, so mock the status explicitly + function mockStatus(status: number, body: unknown): () => string { + let calledUrl = ""; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calledUrl = url; + return { + ok: status >= 200 && status < 300, + status, + json: async () => { + if (status === 204) throw new Error("no body to parse"); + return body; + }, + } as Response; + }), + ); + return () => calledUrl; + } + + const feature: Feature = { + type: "Feature", + properties: {}, + geometry: { type: "Polygon", coordinates: [[[0, 0], [1, 0], [1, 1], [0, 0]]] }, + }; + + it("requests /iatas/{iata}/border", async () => { + const getUrl = mockStatus(200, feature); + await getIataBorder("YOW"); + expect(new URL(getUrl()).pathname).toContain("/iatas/YOW/border"); + }); + + it("returns null for a 204 (no border configured) without parsing a body", async () => { + mockStatus(204, undefined); + await expect(getIataBorder("YOW")).resolves.toBeNull(); + }); + + it("treats a literal null body as no border", async () => { + mockStatus(200, null); + await expect(getIataBorder("YOW")).resolves.toBeNull(); + }); + + it("returns the GeoJSON Feature when a border exists", async () => { + mockStatus(200, feature); + await expect(getIataBorder("YOW")).resolves.toEqual(feature); + }); +}); diff --git a/tests/features/map/map-url.test.ts b/tests/features/map/map-url.test.ts index 877819a2..485bca2d 100644 --- a/tests/features/map/map-url.test.ts +++ b/tests/features/map/map-url.test.ts @@ -70,9 +70,15 @@ describe("parseMapView", () => { expect(parseMapView(new URLSearchParams("flow=x"))).toEqual({}); }); + it("reads the iata-borders toggle on/off", () => { + expect(parseMapView(new URLSearchParams("borders=on"))).toEqual({ borders: true }); + expect(parseMapView(new URLSearchParams("borders=off"))).toEqual({ borders: false }); + expect(parseMapView(new URLSearchParams("borders=x"))).toEqual({}); + }); + it("combines every param into one view", () => { const params = new URLSearchParams( - "lat=53.31&lng=-113.58&zoom=9&clustering=off&node_type=repeater&neighbor_lines=on&style=liberty&flow=on", + "lat=53.31&lng=-113.58&zoom=9&clustering=off&node_type=repeater&neighbor_lines=on&style=liberty&flow=on&borders=on", ); expect(parseMapView(params)).toEqual({ center: [-113.58, 53.31], @@ -82,6 +88,7 @@ describe("parseMapView", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }); }); }); @@ -95,6 +102,7 @@ describe("buildMapParams", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }; it("emits every managed key with rounded camera values", () => { @@ -107,6 +115,7 @@ describe("buildMapParams", () => { neighbor_lines: "on", style: "liberty", flow: "on", + borders: "on", }); }); @@ -124,6 +133,7 @@ describe("buildMapParams", () => { neighborLines: "on", styleId: "liberty", flow: true, + borders: true, }); }); diff --git a/tests/features/map/useMapBordersData.test.ts b/tests/features/map/useMapBordersData.test.ts new file mode 100644 index 00000000..b7ac299b --- /dev/null +++ b/tests/features/map/useMapBordersData.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { mergeBorders } from "../../../src/features/map/useMapBordersData"; +import type { Feature, Polygon } from "geojson"; + +const poly = (id: number): Feature => ({ + type: "Feature", + properties: { name: `p${id}` }, + geometry: { type: "Polygon", coordinates: [[[id, 0], [id + 1, 0], [id + 1, 1], [id, 0]]] }, +}); + +describe("mergeBorders", () => { + it("drops IATAs with no border and stamps the iata onto each feature's properties", () => { + const fc = mergeBorders([ + { iata: "YOW", border: poly(0) }, + { iata: "YYZ", border: null }, + { iata: "YUL", border: poly(5) }, + ]); + + expect(fc.type).toBe("FeatureCollection"); + expect(fc.features).toHaveLength(2); + expect(fc.features.map((f) => f.properties.iata)).toEqual(["YOW", "YUL"]); + // existing properties and geometry survive the merge + expect(fc.features[0]!.properties.name).toBe("p0"); + expect(fc.features[0]!.geometry).toEqual(poly(0).geometry); + }); + + it("returns an empty FeatureCollection when nothing has a border", () => { + const fc = mergeBorders([{ iata: "YOW", border: null }]); + expect(fc.features).toHaveLength(0); + }); +}); From 161f481576ccaa23ed2cd9024bd70a5d93e22d0a Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:01:32 -0400 Subject: [PATCH 057/163] Carry path and endpoint fields from WS observations into packet summaries --- src/features/packets/usePackets.ts | 5 ++ src/types/api.ts | 8 +++ src/types/ws.ts | 4 +- tests/features/packets/usePackets.test.tsx | 64 ++++++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index 35e704ee..5eb8dfda 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -159,6 +159,11 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi id: data.observation.observerId, displayName: data.observation.observerName, iata: data.observation.iata, + pathLength: data.observation.pathLength, + pathBytes: data.observation.pathBytes, + // WS nulls these when the payload type carries no endpoint; the REST shape uses undefined + resolvedSource: data.observation.resolvedSource ?? undefined, + resolvedDestination: data.observation.resolvedDestination ?? undefined, }, }; diff --git a/src/types/api.ts b/src/types/api.ts index 86dfa735..28d09130 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -12,6 +12,14 @@ export interface LatestObserver { id: string; displayName?: string; iata: string; + // path fields land on the REST list from beacon-server ae0669c, and on live rows via the WS feed; + // resolvedSource/Destination are WS-only for now — the list endpoints leave them nil on purpose. + pathLength?: PathLength; + pathBytes?: string; + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; + // per-hop resolved path; WS-only, and only when the connection opts into configure{resolvePath}. + resolvedPath?: ResolvedHop[]; } // packet list and detail shapes diff --git a/src/types/ws.ts b/src/types/ws.ts index ea65403a..7701ccd9 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,6 +1,6 @@ import type { ChannelMessage } from "../features/channels/types"; import type { NodeIATA } from "../features/nodes/types"; -import type { ResolvedHop } from "./api"; +import type { PathLength, ResolvedHop } from "./api"; // individual server-sent message shapes @@ -60,6 +60,8 @@ export interface WsPacketObservation { rssi: number; snr: number; sourceBroker: string; + pathLength?: PathLength; + pathBytes?: string; // per-hop resolved path; populated only when the connection opts in via configure{resolvePath}, // null otherwise. Same shape as the REST Observation.resolvedPath. resolvedPath?: ResolvedHop[] | null; diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index ea5ea7f9..b71f6cd5 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -215,6 +215,70 @@ describe("usePackets server filter", () => { }); }); +describe("usePackets path and endpoint fields", () => { + let qc: QueryClient; + let rafCallbacks: FrameRequestCallback[]; + + beforeEach(() => { + getPackets.mockReset(); + getPackets.mockResolvedValue({ items: [], nextCursor: null }); + qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + rafCallbacks = []; + vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { + rafCallbacks.push(cb); + return rafCallbacks.length; + }); + vi.stubGlobal("cancelAnimationFrame", () => {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + + it("carries path and endpoint fields from the WS observation into latestObserver", () => { + const { result } = renderHook(() => usePackets(false, undefined), { wrapper }); + + act(() => { + result.current.handlePacketObservation({ + packetHash: "AA11", + packet: { + payloadType: 1, + payloadTypeName: "ADVERT", + routeType: 1, + routeTypeName: "FLOOD", + isFirstObservation: true, + observationCount: 1, + }, + observation: { + observerId: "obs-1", + observerName: "Raven", + iata: "YVR", + heardAt: 1700000000, + rssi: -94, + snr: -7.5, + sourceBroker: "b1", + pathLength: { raw: "42", hashSize: 1, hopCount: 2 }, + pathBytes: "7fa4", + resolvedSource: { confidence: "high", nodes: [{ id: "n1", publicKey: "ab", name: "Salish" }] }, + resolvedDestination: null, + }, + }); + rafCallbacks.splice(0).forEach((cb) => cb(0)); + }); + + const obs = result.current.allPackets[0]!.latestObserver; + expect(obs?.pathLength).toEqual({ raw: "42", hashSize: 1, hopCount: 2 }); + expect(obs?.pathBytes).toBe("7fa4"); + expect(obs?.resolvedSource?.nodes[0]!.name).toBe("Salish"); + expect(obs?.resolvedDestination).toBeUndefined(); + }); +}); + function observation(hash: string): WsPacketObservation["data"] { return { packetHash: hash, From 1dbbce1ad853660dbf6ff844da2fa3813c9d9795 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:07:40 -0400 Subject: [PATCH 058/163] Extract usePacketDetail and share it across the analyzer and overlay --- src/App.tsx | 21 +++---------- src/features/packets/usePacketDetail.ts | 14 +++++++++ .../features/packets/usePacketDetail.test.tsx | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+), 17 deletions(-) create mode 100644 src/features/packets/usePacketDetail.ts create mode 100644 tests/features/packets/usePacketDetail.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 463e4d34..9c15bc80 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"; import { BrowserRouter, useSearchParams } from "react-router-dom"; -import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion"; import { ALL_REGIONS, @@ -27,7 +27,7 @@ import { RouteTable } from "./features/routes/RouteTable"; import { TraceList } from "./features/traces/TraceList"; import { ChannelList } from "./features/channels/ChannelList"; import { EmptyState } from "./components/EmptyState"; -import { getPacketDetail } from "./api/client"; +import { usePacketDetail } from "./features/packets/usePacketDetail"; import { WsManager } from "./api/ws-manager"; import { WS_URL, ENABLED_TABS } from "./lib/constants"; import type { PacketDetail } from "./types/api"; @@ -145,21 +145,8 @@ function AppInner() { const [pathMapInitialKey, setPathMapInitialKey] = useState(null); const pathLinkHandledRef = useRef(false); - // short staleTime: observations keep accruing, so reopening the analyzer should show them - // instead of a snapshot frozen at first open - const { data: analyzerDetail, isLoading: analyzerLoading } = useQuery({ - queryKey: ["packet-detail", analyzerHash], - queryFn: () => getPacketDetail(analyzerHash!), - enabled: !!analyzerHash, - staleTime: 30_000, - }); - - const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = useQuery({ - queryKey: ["packet-detail", overlayPacketHash], - queryFn: () => getPacketDetail(overlayPacketHash!), - enabled: !!overlayPacketHash, - staleTime: 30_000, - }); + const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash); + const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash); // deep link: ?hash opens the analyzer drawer; ?path then opens the path popup once, pre-selected useEffect(() => { diff --git a/src/features/packets/usePacketDetail.ts b/src/features/packets/usePacketDetail.ts new file mode 100644 index 00000000..4e11651b --- /dev/null +++ b/src/features/packets/usePacketDetail.ts @@ -0,0 +1,14 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPacketDetail } from "../../api/client"; +import type { PacketDetail } from "../../types/api"; + +// One query per hash shared by the expanded row, the analyzer drawer and the overlay — TanStack +// dedupes, so a row expanded under an open drawer costs a single request. +export function usePacketDetail(hash: string | null) { + return useQuery({ + queryKey: ["packet-detail", hash], + queryFn: () => getPacketDetail(hash!), + enabled: !!hash, + staleTime: 30_000, + }); +} diff --git a/tests/features/packets/usePacketDetail.test.tsx b/tests/features/packets/usePacketDetail.test.tsx new file mode 100644 index 00000000..808799fc --- /dev/null +++ b/tests/features/packets/usePacketDetail.test.tsx @@ -0,0 +1,31 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { usePacketDetail } from "../../../src/features/packets/usePacketDetail"; + +const getPacketDetail = vi.fn(); +vi.mock("../../../src/api/client", () => ({ + getPacketDetail: (hash: string) => getPacketDetail(hash), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +beforeEach(() => getPacketDetail.mockReset()); + +describe("usePacketDetail", () => { + it("does not fetch when hash is null", () => { + renderHook(() => usePacketDetail(null), { wrapper }); + expect(getPacketDetail).not.toHaveBeenCalled(); + }); + + it("fetches the detail for a hash", async () => { + getPacketDetail.mockResolvedValue({ packetHash: "AA11", observations: [] }); + const { result } = renderHook(() => usePacketDetail("AA11"), { wrapper }); + await waitFor(() => expect(result.current.data?.packetHash).toBe("AA11")); + expect(getPacketDetail).toHaveBeenCalledWith("AA11"); + }); +}); From 89a8980212a6df7b308a5cfc473ca1790609b8d4 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:19:09 -0400 Subject: [PATCH 059/163] Split ?hash into row expansion and ?analyze for the analyzer drawer --- src/App.tsx | 19 +++-- src/features/map/PacketPathMapModal.tsx | 2 +- src/features/packets/PacketAnalyzerDrawer.tsx | 6 +- src/features/packets/PacketList.tsx | 8 +-- .../packets/PacketAnalyzerDrawer.test.tsx | 7 +- tests/features/packets/PacketList.test.tsx | 69 ++++++++++--------- 6 files changed, 61 insertions(+), 50 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9c15bc80..53d5a005 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -129,8 +129,9 @@ function AppInner() { // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); - // ?hash / ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button) - const [analyzerHash, setAnalyzerHash] = useState(() => searchParams.get("hash")); + // ?node / ?observer restore a shared deep link on load (see each panel's Copy Link button) + // ?analyze=1 is a boolean flag; the hash always lives in ?hash, so ?analyze alone opens nothing + const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null; const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts @@ -148,7 +149,7 @@ function AppInner() { const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash); const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash); - // deep link: ?hash opens the analyzer drawer; ?path then opens the path popup once, pre-selected + // deep link: ?hash&analyze=1 opens the analyzer drawer; ?path then opens the path popup once, pre-selected useEffect(() => { if (!initialPath || !analyzerDetail || pathLinkHandledRef.current) return; pathLinkHandledRef.current = true; @@ -157,9 +158,14 @@ function AppInner() { }, [initialPath, analyzerDetail]); const handleAnalyze = useCallback((hash: string | null) => { - setAnalyzerHash(hash); setSelectedObservationId(null); - }, []); + setSearchParams((p) => { + const n = new URLSearchParams(p); + if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); } + else n.delete("analyze"); + return n; + }, { replace: true }); + }, [setSearchParams]); const handleTabChange = (tab: string) => { setOverlayNodeId(null); @@ -168,7 +174,6 @@ function AppInner() { // On mobile a detail panel fills the screen, so leaving its tab must close it; desktop side // panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this. if (isMobile) { - setAnalyzerHash(null); setSelectedObservationId(null); setSelectedNodeId(null); setSelectedObserverId(null); @@ -195,7 +200,7 @@ function AppInner() { }, []); // Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet - // analyzer's ?hash cleanup). Selecting a different node/observer doesn't touch the URL — the panel's + // analyzer's ?analyze cleanup). Selecting a different node/observer doesn't touch the URL — the panel's // Copy Link button rebuilds a fresh link on demand. const dropSelectionParam = useCallback((key: "node" | "observer") => { setSearchParams((prev) => { diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index 69619be8..e9c5d4b1 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -56,7 +56,7 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { Packet Path
({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all" })} + params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null })} ariaLabel="Copy path link" /> diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 3ccaac28..3b3b5c7e 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -44,11 +44,11 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); - // drop ?hash so the closed analyzer can't reopen on reload and the packet row deselects + // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { setSearchParams((p) => { const n = new URLSearchParams(p); - n.delete("hash"); + n.delete("analyze"); return n; }, { replace: true }); onClose(); @@ -72,7 +72,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
Packet Analyzer
- {detail && } + {detail && }
diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 7413a4a4..c6bc09fd 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -31,7 +31,8 @@ interface PacketListProps { // main packet view: filters, banner, virtual list -export function PacketList({ wsManager, onAnalyze }: PacketListProps) { +// onAnalyze isn't called here — Task 9's row-expansion "Open analyzer" button will call it +export function PacketList({ wsManager }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); // single-value selections go to the server so scrolling pages through matching history @@ -68,18 +69,17 @@ export function PacketList({ wsManager, onAnalyze }: PacketListProps) { [allPackets, filters, observersByHash], ); - // ?hash is the source of truth — the analyzer drawer clears it on close, deselecting the row + // ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1). const expandedHash = searchParams.get("hash"); const handleToggleExpand = useCallback((hash: string) => { const next = expandedHash === hash ? null : hash; - onAnalyze(next); setSearchParams((p) => { const n = new URLSearchParams(p); if (next) n.set("hash", next); else n.delete("hash"); return n; }, { replace: true }); - }, [expandedHash, setSearchParams, onAnalyze]); + }, [expandedHash, setSearchParams]); useWsPacketHandler(wsManager, handlePacketObservation); useWsLaggedHandler(wsManager, handleLagged); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index bbe42550..c2fa0688 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -11,10 +11,10 @@ function LocationProbe() { } describe("PacketAnalyzerDrawer close", () => { - it("removes ?hash from the URL and calls onClose", () => { + it("removes ?analyze but keeps ?hash, and calls onClose", () => { const onClose = vi.fn(); render( - + , @@ -24,7 +24,8 @@ describe("PacketAnalyzerDrawer close", () => { expect(onClose).toHaveBeenCalledOnce(); const search = screen.getByTestId("search").textContent ?? ""; - expect(search).not.toContain("hash="); + expect(search).not.toContain("analyze="); + expect(search).toContain("hash=abc123"); // row stays expanded expect(search).toContain("tab=Packets"); // other params survive }); }); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 1283b5fd..175ef3a3 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter, useSearchParams } from "react-router-dom"; +import { MemoryRouter } from "react-router-dom"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; import type { PacketSummary } from "../../../src/types/api"; @@ -40,37 +40,35 @@ vi.mock("../../../src/hooks/useWsHandlers", () => ({ // the virtual list needs ResizeObserver in jsdom; stub it down to the expand wiring under test vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ PacketVirtualList: ({ + packets, expandedHash, onToggleExpand, }: { + packets: PacketSummary[]; expandedHash: string | null; onToggleExpand: (hash: string) => void; }) => (
{String(expandedHash)}
- + {packets.map((p) => ( + + ))}
), })); -// stands in for the analyzer drawer's close button, which clears ?hash from outside PacketList -function ExternalHashCloser() { - const [, setSearchParams] = useSearchParams(); - return ( - - ); -} +const packet = (hash: string): PacketSummary => ({ + packetHash: hash, payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 1, +}); describe("PacketList server filter wiring", () => { function renderAt(url: string) { @@ -140,24 +138,31 @@ describe("PacketList loading feedback", () => { }); describe("PacketList expanded row", () => { - it("follows the ?hash param so an external analyzer close deselects the row", () => { + it("expands a row from ?hash without opening the analyzer", () => { const onAnalyze = vi.fn(); + usePackets.mockReturnValue({ ...basePackets(), allPackets: [packet("AA11")] }); + render( - - - + + , ); - expect(screen.getByTestId("expanded").textContent).toBe("h1"); + expect(screen.getByRole("button", { name: /AA11/ })).toHaveAttribute("aria-expanded", "true"); + expect(onAnalyze).not.toHaveBeenCalled(); + }); + + it("clicking a row sets ?hash and does not open the analyzer", () => { + const onAnalyze = vi.fn(); + usePackets.mockReturnValue({ ...basePackets(), allPackets: [packet("AA11")] }); - // analyzer drawer closed elsewhere — row must deselect - fireEvent.click(screen.getByText("close-url")); - expect(screen.getByTestId("expanded").textContent).toBe("null"); + render( + + + , + ); - // clicking the same row again must reopen, not collapse - fireEvent.click(screen.getByText("toggle-h1")); - expect(screen.getByTestId("expanded").textContent).toBe("h1"); - expect(onAnalyze).toHaveBeenLastCalledWith("h1"); + fireEvent.click(screen.getByRole("button", { name: /AA11/ })); + expect(onAnalyze).not.toHaveBeenCalled(); }); }); From c9eb929a81ae62a233452fe16614ae7b239b8b36 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:35:45 -0400 Subject: [PATCH 060/163] Fix ?path deep link when ?analyze is absent --- src/App.tsx | 41 ++++++++++++++++++----- src/features/packets/PacketList.tsx | 2 +- tests/App.pathLinkRestore.test.tsx | 52 +++++++++++++++++++++++++++++ 3 files changed, 86 insertions(+), 9 deletions(-) create mode 100644 tests/App.pathLinkRestore.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 53d5a005..fd6cb68a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -98,6 +98,29 @@ function RegionUrlSync() { return null; } +// Restores a shared "?path" link once its detail arrives. A copied path link carries ?hash without +// ?analyze (PacketPathMapModal's Copy Link strips it), so this can't reuse the analyzer drawer's fetch +// and needs its own — sharing usePacketDetail's query cache means that costs nothing extra when both +// params are present. +export function PathLinkRestore({ initialPath, hash, analyzerDetail, onRestore }: { + initialPath: string | null; + hash: string | null; + analyzerDetail: PacketDetail | undefined; + onRestore: (detail: PacketDetail, key: string) => void; +}) { + const { data: pathLinkDetail } = usePacketDetail(initialPath ? hash : null); + const handledRef = useRef(false); + + useEffect(() => { + const detail = analyzerDetail ?? pathLinkDetail; + if (!initialPath || !detail || handledRef.current) return; + handledRef.current = true; + onRestore(detail, initialPath); + }, [initialPath, analyzerDetail, pathLinkDetail, onRestore]); + + return null; +} + // Drop the shared node/observer selection when the user changes region, so a detail panel doesn't keep // showing an entity that's no longer in the re-queried map/table. Watches the raw selection rather than // the resolved regionKey: the async slug→IATA expansion on load bumps regionKey without any user action, @@ -144,18 +167,14 @@ function AppInner() { const [pathMapDetail, setPathMapDetail] = useState(null); const [initialPath] = useState(() => searchParams.get("path")); const [pathMapInitialKey, setPathMapInitialKey] = useState(null); - const pathLinkHandledRef = useRef(false); const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash); const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash); - // deep link: ?hash&analyze=1 opens the analyzer drawer; ?path then opens the path popup once, pre-selected - useEffect(() => { - if (!initialPath || !analyzerDetail || pathLinkHandledRef.current) return; - pathLinkHandledRef.current = true; - setPathMapDetail(analyzerDetail); - setPathMapInitialKey(initialPath); - }, [initialPath, analyzerDetail]); + const handlePathLinkRestore = useCallback((detail: PacketDetail, key: string) => { + setPathMapDetail(detail); + setPathMapInitialKey(key); + }, []); const handleAnalyze = useCallback((hash: string | null) => { setSelectedObservationId(null); @@ -262,6 +281,12 @@ function AppInner() { +
diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index c6bc09fd..f33e32d5 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -31,7 +31,7 @@ interface PacketListProps { // main packet view: filters, banner, virtual list -// onAnalyze isn't called here — Task 9's row-expansion "Open analyzer" button will call it +// onAnalyze isn't called here — it's retained for the row-expansion's future "Open analyzer" button export function PacketList({ wsManager }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); diff --git a/tests/App.pathLinkRestore.test.tsx b/tests/App.pathLinkRestore.test.tsx new file mode 100644 index 00000000..787a4fea --- /dev/null +++ b/tests/App.pathLinkRestore.test.tsx @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { PathLinkRestore } from "../src/App"; +import type { PacketDetail } from "../src/types/api"; + +const getPacketDetail = vi.fn(); +vi.mock("../src/api/client", () => ({ + getPacketDetail: (hash: string) => getPacketDetail(hash), +})); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + +); + +beforeEach(() => getPacketDetail.mockReset()); + +const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; + +describe("PathLinkRestore", () => { + // Regression: PacketPathMapModal's Copy Link strips ?analyze, so a copied path link carries ?hash + // without it — the popup can't rely on the analyzer drawer's fetch and needs its own. + it("restores the path popup from ?hash&?path alone, with no ?analyze", async () => { + getPacketDetail.mockResolvedValue(detail); + const onRestore = vi.fn(); + render( + , + { wrapper }, + ); + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha")); + expect(getPacketDetail).toHaveBeenCalledWith("AA11"); + }); + + it("does not fetch when there is no ?path", () => { + render( + , + { wrapper }, + ); + expect(getPacketDetail).not.toHaveBeenCalled(); + }); + + it("uses the analyzer's already-fetched detail instead of waiting on its own fetch", () => { + const onRestore = vi.fn(); + render( + , + { wrapper }, + ); + expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha"); + }); +}); From 75124250d6946953f00b20ad844e6814176af1c9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:44:40 -0400 Subject: [PATCH 061/163] Add a pure path-summary model for the packet table's path line --- src/features/packets/path-summary.ts | 95 ++++++++++++++++++ tests/features/packets/path-summary.test.ts | 104 ++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 src/features/packets/path-summary.ts create mode 100644 tests/features/packets/path-summary.test.ts diff --git a/src/features/packets/path-summary.ts b/src/features/packets/path-summary.ts new file mode 100644 index 00000000..8df354d6 --- /dev/null +++ b/src/features/packets/path-summary.ts @@ -0,0 +1,95 @@ +import type { PacketSummary, ResolvedHop } from "../../types/api"; +import type { PathConfidence } from "../../types/enums"; +import { PayloadType } from "../../types/enums"; + +// How many hops line 2 is allowed to spend on chips before it truncates. An unresolved run costs +// its own length, so "?×4" uses four of the five. +export const MAX_PATH_HOPS_SHOWN = 5; + +export type PathChip = + | { kind: "node"; label: string; confidence: PathConfidence } + | { kind: "hex"; label: string } + | { kind: "unresolved-run"; count: number }; + +export interface PathSummary { + hopLabel: string; + chips: PathChip[]; + overflow: number; // hops not represented by a visible chip + source: PathChip | null; + destination: PathChip | null; + isNa: boolean; +} + +const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, source: null, destination: null, isNa: true }; + +function hopChip(hop: ResolvedHop): PathChip | null { + if (hop.confidence === "none") return null; + const node = hop.nodes[0]; + const label = node?.name ?? node?.publicKey.slice(0, 8) ?? "?"; + return { kind: "node", label, confidence: hop.confidence }; +} + +// Endpoints are single hops and never collapse — an unresolved one shows "?" rather than a run. +function endpointChip(hop: ResolvedHop | undefined): PathChip | null { + if (!hop) return null; + return hopChip(hop) ?? { kind: "unresolved-run", count: 1 }; +} + +function chipsFromResolved(path: ResolvedHop[]): PathChip[] { + const out: PathChip[] = []; + for (const hop of path) { + const chip = hopChip(hop); + if (chip) { out.push(chip); continue; } + const last = out[out.length - 1]; + if (last?.kind === "unresolved-run") last.count += 1; + else out.push({ kind: "unresolved-run", count: 1 }); + } + return out; +} + +function chipsFromHex(pathBytes: string, hashSize: number): PathChip[] { + const width = hashSize * 2; + const out: PathChip[] = []; + for (let i = 0; i < pathBytes.length; i += width) { + out.push({ kind: "hex", label: pathBytes.slice(i, i + width) }); + } + return out; +} + +export function buildPathSummary(packet: PacketSummary): PathSummary { + const observer = packet.latestObserver; + const length = observer?.pathLength; + // No hashSize means no way to split pathBytes — never guess a chunk width. + if (!observer || !length) return NA; + + const { hopCount, hashSize } = length; + const source = endpointChip(observer.resolvedSource); + const destination = endpointChip(observer.resolvedDestination); + const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, source, destination, isNa: false }; + + if (hopCount === 0) return { ...base, hopLabel: "0 hops · direct" }; + + // TRACE repurposes the path field to carry per-hop SNR samples, so its bytes are not hashes. + // Only the detail endpoint swaps in real trace hashes; the list and WS never do. + if (packet.payloadType === PayloadType.TRACE) return base; + + const bytes = observer.pathBytes; + const resolved = observer.resolvedPath; + const all = resolved?.length + ? chipsFromResolved(resolved) + : bytes && bytes.length === hopCount * hashSize * 2 + ? chipsFromHex(bytes, hashSize) + : []; + if (all.length === 0) return base; + + const chips: PathChip[] = []; + let spent = 0; + for (const chip of all) { + const cost = chip.kind === "unresolved-run" ? chip.count : 1; + if (chips.length > 0 && spent + cost > MAX_PATH_HOPS_SHOWN) break; + chips.push(chip); + spent += cost; + } + + return { ...base, chips, overflow: Math.max(0, hopCount - spent) }; +} diff --git a/tests/features/packets/path-summary.test.ts b/tests/features/packets/path-summary.test.ts new file mode 100644 index 00000000..cc36d858 --- /dev/null +++ b/tests/features/packets/path-summary.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect } from "vitest"; +import { buildPathSummary } from "../../../src/features/packets/path-summary"; +import type { PacketSummary } from "../../../src/types/api"; +import { PayloadType } from "../../../src/types/enums"; + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, ...over, +}); +const obs = (o: object) => ({ latestObserver: { id: "o1", iata: "YVR", ...o } }); + +describe("buildPathSummary", () => { + it("is n/a when latestObserver is absent", () => { + expect(buildPathSummary(pkt()).isNa).toBe(true); + }); + + it("never claims direct reception for an unknown hop count", () => { + const s = buildPathSummary(pkt(obs({}))); + expect(s.hopLabel).toBe("n/a"); + expect(s.isNa).toBe(true); + }); + + it("labels a literal zero hop count as direct", () => { + const s = buildPathSummary(pkt(obs({ pathLength: { raw: "00", hashSize: 1, hopCount: 0 } }))); + expect(s.hopLabel).toBe("0 hops · direct"); + expect(s.chips).toEqual([]); + expect(s.isNa).toBe(false); + }); + + it("is n/a when pathLength is missing even if pathBytes is present", () => { + expect(buildPathSummary(pkt(obs({ pathBytes: "7fa4" }))).isNa).toBe(true); + }); + + it("renders hex chips split by hashSize when there is no resolved path", () => { + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "42", hashSize: 1, hopCount: 2 }, pathBytes: "7fa4", + }))); + expect(s.hopLabel).toBe("2 hops"); + expect(s.chips).toEqual([{ kind: "hex", label: "7f" }, { kind: "hex", label: "a4" }]); + expect(s.overflow).toBe(0); + }); + + it("drops chips when pathBytes length disagrees with hopCount x hashSize", () => { + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "42", hashSize: 1, hopCount: 5 }, pathBytes: "7fa4", + }))); + expect(s.hopLabel).toBe("5 hops"); + expect(s.chips).toEqual([]); + }); + + it("truncates past the hop budget and counts remaining hops", () => { + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "4e", hashSize: 1, hopCount: 14 }, + pathBytes: "000102030405060708090a0b0c0d", + }))); + expect(s.chips).toHaveLength(5); + expect(s.overflow).toBe(9); + }); + + it("collapses a run of unresolved hops into one chip that costs its length", () => { + const none = { confidence: "none" as const, nodes: [] }; + const high = { confidence: "high" as const, nodes: [{ id: "n", publicKey: "ab", name: "Raven" }] }; + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "43", hashSize: 1, hopCount: 4 }, pathBytes: "00010203", + resolvedPath: [high, none, none, high], + }))); + expect(s.chips).toEqual([ + { kind: "node", label: "Raven", confidence: "high" }, + { kind: "unresolved-run", count: 2 }, + { kind: "node", label: "Raven", confidence: "high" }, + ]); + expect(s.overflow).toBe(0); + }); + + it("always shows at least one chip even when the first run exceeds the budget", () => { + const none = { confidence: "none" as const, nodes: [] }; + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "4a", hashSize: 1, hopCount: 10 }, + pathBytes: "00010203040506070809", + resolvedPath: Array(10).fill(none), + }))); + expect(s.chips).toEqual([{ kind: "unresolved-run", count: 10 }]); + expect(s.overflow).toBe(0); + }); + + it("shows hop count only for TRACE packets", () => { + const s = buildPathSummary(pkt({ + payloadType: PayloadType.TRACE, + ...obs({ pathLength: { raw: "43", hashSize: 1, hopCount: 3 }, pathBytes: "000102" }), + })); + expect(s.hopLabel).toBe("3 hops"); + expect(s.chips).toEqual([]); + }); + + it("carries resolved endpoints through", () => { + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f", + resolvedSource: { confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Salish" }] }, + }))); + expect(s.source).toEqual({ kind: "node", label: "Salish", confidence: "high" }); + expect(s.destination).toBeNull(); + }); +}); From 48587a04375736b0eed1a65464e3563b8088dba8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:52:10 -0400 Subject: [PATCH 062/163] Cover pathBytes-absent and unresolved-endpoint cases in path summary tests --- tests/features/packets/path-summary.test.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/features/packets/path-summary.test.ts b/tests/features/packets/path-summary.test.ts index cc36d858..e17ed594 100644 --- a/tests/features/packets/path-summary.test.ts +++ b/tests/features/packets/path-summary.test.ts @@ -49,6 +49,15 @@ describe("buildPathSummary", () => { expect(s.chips).toEqual([]); }); + it("renders hop count with no chips when pathBytes is absent entirely", () => { + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "43", hashSize: 1, hopCount: 3 }, + }))); + expect(s.hopLabel).toBe("3 hops"); + expect(s.chips).toEqual([]); + expect(s.isNa).toBe(false); + }); + it("truncates past the hop budget and counts remaining hops", () => { const s = buildPathSummary(pkt(obs({ pathLength: { raw: "4e", hashSize: 1, hopCount: 14 }, @@ -101,4 +110,12 @@ describe("buildPathSummary", () => { expect(s.source).toEqual({ kind: "node", label: "Salish", confidence: "high" }); expect(s.destination).toBeNull(); }); + + it("falls back to an unresolved-run chip for an endpoint with confidence none", () => { + const s = buildPathSummary(pkt(obs({ + pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f", + resolvedSource: { confidence: "none", nodes: [] }, + }))); + expect(s.source).toEqual({ kind: "unresolved-run", count: 1 }); + }); }); From bdf7ac45e46aa8c24b5139cbebff9f5776d755d8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 18:56:03 -0400 Subject: [PATCH 063/163] Render the packet path line with truncation and confidence tints --- src/features/packets/PacketPathLine.tsx | 57 +++++++++++++++++++ src/features/packets/packet-grid.ts | 3 + .../features/packets/PacketPathLine.test.tsx | 41 +++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 src/features/packets/PacketPathLine.tsx create mode 100644 src/features/packets/packet-grid.ts create mode 100644 tests/features/packets/PacketPathLine.test.tsx diff --git a/src/features/packets/PacketPathLine.tsx b/src/features/packets/PacketPathLine.tsx new file mode 100644 index 00000000..427dde61 --- /dev/null +++ b/src/features/packets/PacketPathLine.tsx @@ -0,0 +1,57 @@ +import type { PacketSummary } from "../../types/api"; +import type { PathConfidence } from "../../types/enums"; +import { buildPathSummary, type PathChip } from "./path-summary"; + +// Same three-state vocabulary PathData uses in the analyzer. +const CONFIDENCE_CLASSES: Record = { + high: "bg-green/8 text-green", + ambiguous: "bg-warn/8 text-warn", + none: "bg-text-muted/8 text-text-dim", +}; + +function Chip({ chip }: { chip: PathChip }) { + if (chip.kind === "hex") { + return {chip.label}; + } + if (chip.kind === "unresolved-run") { + return ( + + {chip.count === 1 ? "?" : `?×${chip.count}`} + + ); + } + return ( + + {chip.label} + + ); +} + +// Line 2 of a packet row: always the latest observation's path, never the selected one. +export function PacketPathLine({ packet }: { packet: PacketSummary }) { + const summary = buildPathSummary(packet); + + if (summary.isNa) { + return n/a; + } + + return ( +
+ latest + {summary.hopLabel} + {summary.chips.map((chip, i) => )} + {summary.overflow > 0 && ( + + +{summary.overflow} more + + )} + {(summary.source || summary.destination) && ( + + {summary.source ? : n/a} + → + {summary.destination ? : n/a} + + )} +
+ ); +} diff --git a/src/features/packets/packet-grid.ts b/src/features/packets/packet-grid.ts new file mode 100644 index 00000000..0a5aec0e --- /dev/null +++ b/src/features/packets/packet-grid.ts @@ -0,0 +1,3 @@ +// One track list shared by the sticky header and every row, so columns stay aligned. Line 2 spans +// all of it via grid-column: 1 / -1. +export const GRID_TEMPLATE = "1.25rem minmax(7ch,auto) 6rem 5rem 3rem minmax(8rem,1fr) 3.5rem 5rem"; diff --git a/tests/features/packets/PacketPathLine.test.tsx b/tests/features/packets/PacketPathLine.test.tsx new file mode 100644 index 00000000..3551d52d --- /dev/null +++ b/tests/features/packets/PacketPathLine.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PacketPathLine } from "../../../src/features/packets/PacketPathLine"; +import type { PacketSummary } from "../../../src/types/api"; + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, ...over, +}); + +describe("PacketPathLine", () => { + it("renders a single n/a when there is nothing to show", () => { + render(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + it("renders hop label, hex chips and the overflow count", () => { + render(); + expect(screen.getByText("14 hops")).toBeInTheDocument(); + expect(screen.getByText("00")).toBeInTheDocument(); + expect(screen.getByText("+9 more")).toBeInTheDocument(); + }); + + it("tints an ambiguous hop with the warn token", () => { + render(); + expect(screen.getByText("Raven").className).toContain("text-warn"); + }); +}); From dbed1496797b3a41489eab2e259626876679ad04 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:01:34 -0400 Subject: [PATCH 064/163] Cover endpoint pairs, high tint, and unresolved-run chips in PacketPathLine --- .../features/packets/PacketPathLine.test.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/features/packets/PacketPathLine.test.tsx b/tests/features/packets/PacketPathLine.test.tsx index 3551d52d..0843834b 100644 --- a/tests/features/packets/PacketPathLine.test.tsx +++ b/tests/features/packets/PacketPathLine.test.tsx @@ -38,4 +38,77 @@ describe("PacketPathLine", () => { })} />); expect(screen.getByText("Raven").className).toContain("text-warn"); }); + + it("tints a high-confidence hop with the green token", () => { + render(); + expect(screen.getByText("Falcon").className).toContain("text-green"); + }); + + it("renders both endpoints with the arrow glyph between them", () => { + render(); + expect(screen.getByText("SrcNode")).toBeInTheDocument(); + expect(screen.getByText("DstNode")).toBeInTheDocument(); + expect(screen.getByText("→")).toBeInTheDocument(); + }); + + it("shows n/a for a missing endpoint while the present one still renders", () => { + render(); + expect(screen.getByText("SrcNode")).toBeInTheDocument(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + it("omits the endpoint block entirely when both endpoints are absent", () => { + render(); + expect(screen.queryByText("→")).not.toBeInTheDocument(); + }); + + it("renders a bare ? for a single unresolved hop", () => { + render(); + expect(screen.getByText("?")).toBeInTheDocument(); + }); + + it("collapses a run of unresolved hops into a single ?×N chip", () => { + render(); + expect(screen.getByText("?×3")).toBeInTheDocument(); + }); }); From 8795c360ff70ebba3c021b9f8f5474d9a46bd451 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:10:07 -0400 Subject: [PATCH 065/163] Add the two-line packet table row --- src/features/packets/PacketTableRow.tsx | 72 +++++++++++++++++++ .../features/packets/PacketTableRow.test.tsx | 68 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 src/features/packets/PacketTableRow.tsx create mode 100644 tests/features/packets/PacketTableRow.test.tsx diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx new file mode 100644 index 00000000..68b3212b --- /dev/null +++ b/src/features/packets/PacketTableRow.tsx @@ -0,0 +1,72 @@ +import { formatHex } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { Badge } from "../../components/Badge"; +import { ScopeTag } from "../../components/ScopeTag"; +import { payloadTypeVariant } from "../../components/badge-utils"; +import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums"; +import type { PacketSummary } from "../../types/api"; +import { GRID_TEMPLATE } from "./packet-grid"; +import { PacketPathLine } from "./PacketPathLine"; + +interface PacketTableRowProps { + packet: PacketSummary; + expanded: boolean; + isFresh?: boolean; + onToggle: () => void; +} + +// two-line table row: line 1 is the identity grid (shares GRID_TEMPLATE with the header), line 2 +// is the latest path. The whole row is the expansion click target. +export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { + const observer = packet.latestObserver; + + return ( +
+ + +
+ +
+
+ ); +} diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx new file mode 100644 index 00000000..3e55fb26 --- /dev/null +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketTableRow } from "../../../src/features/packets/PacketTableRow"; +import type { PacketSummary } from "../../../src/types/api"; + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over, +}); + +describe("PacketTableRow", () => { + it("exposes one button carrying the expansion state", () => { + render( {}} />); + const btn = screen.getByRole("button"); + expect(btn).toHaveAttribute("aria-expanded", "false"); + }); + + it("toggles on click", () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button")); + expect(onToggle).toHaveBeenCalledOnce(); + }); + + it("renders line 2 even when there is no path data, so row height is constant", () => { + render( {}} />); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + it("falls back to the observer id when there is no display name", () => { + render( {}} />); + expect(screen.getByText("abcdef12")).toBeInTheDocument(); + expect(screen.getByText("YVR")).toBeInTheDocument(); + }); + + it("prefers the observer display name over the id", () => { + render( {}} />); + expect(screen.getByText("Cypress Peak")).toBeInTheDocument(); + expect(screen.queryByText("abcdef12")).not.toBeInTheDocument(); + }); + + it("reflects the expanded state on the button and chevron", () => { + render( {}} />); + expect(screen.getByRole("button")).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("›")).toHaveClass("rotate-90"); + }); + + it("marks a fresh row with the pulse class", () => { + const { container } = render( {}} />); + expect(container.querySelector(".packet-fresh")).toBeInTheDocument(); + }); + + it("renders a scope tag when the packet has a scope", () => { + render( {}} />); + expect(screen.getByText("#bc")).toBeInTheDocument(); + }); + + it("falls back to the raw payload type name for an unrecognized payload type", () => { + render( {}} />); + expect(screen.getByText("CUSTOM_99")).toBeInTheDocument(); + }); + + it("falls back to Unknown when routeTypeName is empty", () => { + render( {}} />); + expect(screen.getByText("Unknown")).toBeInTheDocument(); + }); +}); From 8bd0f92aff355d8925cd9c14316fa8a4a6861ffd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:17:03 -0400 Subject: [PATCH 066/163] Restore n/a fallback in observer and IATA cells, scope the covering test --- src/features/packets/PacketTableRow.tsx | 4 ++-- tests/features/packets/PacketTableRow.test.tsx | 10 ++++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 68b3212b..5433e94b 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -54,10 +54,10 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa ×{packet.observationCount} - {observer ? (observer.displayName ?? observer.id.slice(0, 8)) : —} + {observer ? (observer.displayName ?? observer.id.slice(0, 8)) : n/a} - {observer?.iata ?? —} + {observer?.iata ?? n/a} diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 3e55fb26..7a5e7c4e 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { PacketTableRow } from "../../../src/features/packets/PacketTableRow"; import type { PacketSummary } from "../../../src/types/api"; @@ -25,7 +25,13 @@ describe("PacketTableRow", () => { it("renders line 2 even when there is no path data, so row height is constant", () => { render( {}} />); - expect(screen.getByText("n/a")).toBeInTheDocument(); + expect(screen.getAllByText("n/a")).toHaveLength(3); + }); + + it("falls back to n/a in the observer and IATA cells when there is no observer", () => { + render( {}} />); + const line1 = within(screen.getByRole("button")); + expect(line1.getAllByText("n/a")).toHaveLength(2); }); it("falls back to the observer id when there is no display name", () => { From 9300eefad101b2f82ef8162b399b250c00d51af0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:20:10 -0400 Subject: [PATCH 067/163] Add the sticky packet table header --- src/features/packets/PacketTableHeader.tsx | 20 ++++++++++ .../packets/PacketTableHeader.test.tsx | 37 +++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 src/features/packets/PacketTableHeader.tsx create mode 100644 tests/features/packets/PacketTableHeader.test.tsx diff --git a/src/features/packets/PacketTableHeader.tsx b/src/features/packets/PacketTableHeader.tsx new file mode 100644 index 00000000..a9bb5682 --- /dev/null +++ b/src/features/packets/PacketTableHeader.tsx @@ -0,0 +1,20 @@ +import { GRID_TEMPLATE } from "./packet-grid"; + +// Sticky above the virtualizer's spacer, never inside measured item space. +export function PacketTableHeader() { + return ( +
+ + Hash + Type + Route + Obs + Observer + IATA + Age +
+ ); +} diff --git a/tests/features/packets/PacketTableHeader.test.tsx b/tests/features/packets/PacketTableHeader.test.tsx new file mode 100644 index 00000000..1b7ecadd --- /dev/null +++ b/tests/features/packets/PacketTableHeader.test.tsx @@ -0,0 +1,37 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PacketTableHeader } from "../../../src/features/packets/PacketTableHeader"; +import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid"; + +describe("PacketTableHeader", () => { + it("declares every column heading", () => { + render(); + for (const h of ["Hash", "Type", "Route", "Obs", "Observer", "IATA", "Age"]) { + expect(screen.getByText(h)).toBeInTheDocument(); + } + }); + + it("is hidden below md", () => { + const { container } = render(); + expect(container.firstElementChild?.className).toContain("hidden"); + expect(container.firstElementChild?.className).toContain("md:grid"); + }); + + it("applies the shared GRID_TEMPLATE so columns align with the row", () => { + const { container } = render(); + const el = container.firstElementChild as HTMLElement; + expect(el.style.gridTemplateColumns).toBe(GRID_TEMPLATE); + }); + + it("has exactly 8 cells, one per row column including the chevron spacer", () => { + const { container } = render(); + expect(container.firstElementChild?.children).toHaveLength(8); + }); + + it("leaves the leading chevron-alignment cell unlabeled and hidden from screen readers", () => { + const { container } = render(); + const first = container.firstElementChild?.children[0]; + expect(first).toHaveAttribute("aria-hidden"); + expect(first?.textContent).toBe(""); + }); +}); From 7dfb3632654bcf0d6fe89ab0ef155b77c297afdc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:24:39 -0400 Subject: [PATCH 068/163] Add the nested observation table for expanded packet rows --- src/features/packets/ObservationTable.tsx | 61 ++++++++++++ .../packets/ObservationTable.test.tsx | 92 +++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 src/features/packets/ObservationTable.tsx create mode 100644 tests/features/packets/ObservationTable.test.tsx diff --git a/src/features/packets/ObservationTable.tsx b/src/features/packets/ObservationTable.tsx new file mode 100644 index 00000000..36d0da1b --- /dev/null +++ b/src/features/packets/ObservationTable.tsx @@ -0,0 +1,61 @@ +import type { Observation } from "../../types/api"; +import { formatSnr, formatPropagation, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { PathData } from "./PathData"; + +interface Props { + observations: Observation[]; + selectedId: number | null; + onSelect: (id: number) => void; +} + +// per-observer view of a packet: signal readings and path differ row to row since observers sit at +// different distances from the origin. Presentational — the caller owns selection and ordering. +export function ObservationTable({ observations, selectedId, onSelect }: Props) { + return ( + + + + + + + + + + + + + + + {observations.map((o) => { + const level = snrLevel(o.snr); + return ( + onSelect(o.id)} + className={`cursor-pointer border-t border-border-subtle ${o.id === selectedId ? "bg-primary/8" : "hover:bg-bg-raised/40"}`} + > + + + + + + + + + + ); + })} + +
ObserverIATAHeardSNRRSSIPropHopsPath
{o.observerName ?? o.observerId.slice(0, 8)}{o.iata} + {formatSnr(o.snr)} + {o.rssi ?? "—"}{formatPropagation(o.propagationTimeMs)}{o.pathLength.hopCount} + {o.pathBytes ? ( + + ) : ( + — + )} +
+ ); +} diff --git a/tests/features/packets/ObservationTable.test.tsx b/tests/features/packets/ObservationTable.test.tsx new file mode 100644 index 00000000..3fbd120c --- /dev/null +++ b/tests/features/packets/ObservationTable.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { ObservationTable } from "../../../src/features/packets/ObservationTable"; +import type { Observation } from "../../../src/types/api"; + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +describe("ObservationTable", () => { + it("renders one row per observation in the given order", () => { + render( {}} />); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("selects an observation on click", () => { + const onSelect = vi.fn(); + render(); + fireEvent.click(screen.getByText("Observer 1")); + expect(onSelect).toHaveBeenCalledWith(1); + }); + + it("marks the selected row", () => { + render( {}} />); + expect(screen.getByRole("row", { selected: true })).toBeInTheDocument(); + }); + + it("leaves an unselected row's aria-selected false", () => { + render( {}} />); + const rows = screen.getAllByRole("row").slice(1); // drop the header row + expect(rows[0]).toHaveAttribute("aria-selected", "true"); + expect(rows[1]).toHaveAttribute("aria-selected", "false"); + }); + + it("falls back to a truncated observer id when the name is missing", () => { + render( {}} />); + expect(screen.getByText("abcdefgh")).toBeInTheDocument(); + }); + + it("renders em dashes for every absent optional field and no path row", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cells = within(row).getAllByRole("cell"); + expect(cells[3]).toHaveTextContent("—"); // SNR + expect(cells[4]).toHaveTextContent("—"); // RSSI + expect(cells[5]).toHaveTextContent("—"); // Prop + expect(cells[7]).toHaveTextContent("—"); // Path + }); + + it("renders every field when all optional data is present", () => { + render( + {}} + />, + ); + const row = screen.getAllByRole("row")[1]!; + const cells = within(row).getAllByRole("cell"); + expect(cells[3]).toHaveTextContent("6.20"); + expect(cells[4]).toHaveTextContent("-87"); + expect(cells[5]).toHaveTextContent("1.234s"); + expect(cells[6]).toHaveTextContent("1"); + expect(within(cells[7]!).getByText("AB")).toBeInTheDocument(); + }); + + it("colors a good SNR", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-green"); + }); + + it("colors a mid SNR", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-warn"); + }); + + it("colors a bad SNR", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-danger"); + }); +}); From 16ee220abe056e76587394f43d69c448781d428f Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:30:16 -0400 Subject: [PATCH 069/163] Fix PathData sizing and add null-SNR test to ObservationTable --- src/features/packets/ObservationTable.tsx | 5 ++--- tests/features/packets/ObservationTable.test.tsx | 7 +++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/features/packets/ObservationTable.tsx b/src/features/packets/ObservationTable.tsx index 36d0da1b..f81b3898 100644 --- a/src/features/packets/ObservationTable.tsx +++ b/src/features/packets/ObservationTable.tsx @@ -9,8 +9,7 @@ interface Props { onSelect: (id: number) => void; } -// per-observer view of a packet: signal readings and path differ row to row since observers sit at -// different distances from the origin. Presentational — the caller owns selection and ordering. +// Per-observer readings vary by distance; presentational component owned by caller. export function ObservationTable({ observations, selectedId, onSelect }: Props) { return ( @@ -47,7 +46,7 @@ export function ObservationTable({ observations, selectedId, onSelect }: Props) {columns.map((col) => { + const id = col.id ?? col.header; if (!col.sortValue) { - return ; + return ; } - const active = sort.header === col.header; + const active = sort.id === id; return ( - + ))} ); diff --git a/src/components/Timestamp.tsx b/src/components/Timestamp.tsx index 4a1cab26..764c07fd 100644 --- a/src/components/Timestamp.tsx +++ b/src/components/Timestamp.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import { useTick } from "../hooks/useTick"; import { Tooltip } from "./Tooltip"; import { timeAgoMs, formatAbsolute } from "../lib/formatters"; @@ -14,9 +15,10 @@ interface TimestampProps { // attribute lagged ~1s); "absolute" mode flips the two. Self-refreshes via the shared ticker, so // callers don't sprinkle useTick() or build their own tooltips. export function Timestamp({ value, mode = "relative", ms, className }: TimestampProps) { + const { t } = useTranslation(); useTick(); // keep the relative label fresh - const relative = `${timeAgoMs(value)} ago`; + const relative = t("timestamp.ago", { duration: timeAgoMs(value) }); const absolute = formatAbsolute(value, { ms }); return ( diff --git a/src/features/stats/ClockDriftTab.tsx b/src/features/stats/ClockDriftTab.tsx index 7cc1be86..9d022f96 100644 --- a/src/features/stats/ClockDriftTab.tsx +++ b/src/features/stats/ClockDriftTab.tsx @@ -1,3 +1,5 @@ +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useClockDrift } from "./useStats"; import { DataTable, type Column } from "../../components/DataTable"; import { Badge } from "../../components/Badge"; @@ -11,60 +13,66 @@ function driftClass(seconds: number) { return Math.abs(seconds) >= 3600 ? "text-danger" : "text-warn"; } -const columns: Column[] = [ - { - header: "Node", - cell: (e) => ( -
- - {e.nodeName ?? e.nodeId.slice(0, 8)} - - {e.nodeTypeName} -
- ), - sortValue: (e) => e.nodeName ?? e.nodeId, - }, - { - header: "Drift", - className: "tabular-nums", - cell: (e) => {formatClockDrift(e.clockDriftSeconds)}, - sortValue: (e) => Math.abs(e.clockDriftSeconds), - }, - { - header: "Checked", - cell: (e) => , - sortValue: (e) => e.clockCheckedAt, - }, - { - header: "IATAs", - cell: (e) => ( -
- {(e.iatas ?? []).map((i) => ( - {i.iata} - ))} -
- ), - }, -]; - // Repeaters/room servers whose advert-derived clock has drifted past the server threshold, worst // first. Not time-windowed (each row is the node's latest reading), so there's no range selector. export function ClockDriftTab() { + const { t } = useTranslation(); const clockDrift = useClockDrift(); + const loading = clockDrift.isPending || clockDrift.isLoading || clockDrift.isPlaceholderData; + const columns = useMemo[]>(() => [ + { + id: "node", + header: t("clockDrift.node"), + cell: (e) => ( +
+ + {e.nodeName ?? e.nodeId.slice(0, 8)} + + {e.nodeTypeName} +
+ ), + sortValue: (e) => e.nodeName ?? e.nodeId, + }, + { + id: "drift", + header: t("clockDrift.drift"), + className: "tabular-nums", + cell: (e) => {formatClockDrift(e.clockDriftSeconds, { inSync: t("clockDrift.inSync"), ahead: t("clockDrift.ahead"), behind: t("clockDrift.behind") })}, + sortValue: (e) => Math.abs(e.clockDriftSeconds), + }, + { + id: "checked", + header: t("clockDrift.checked"), + cell: (e) => , + sortValue: (e) => e.clockCheckedAt, + }, + { + id: "iatas", + header: "IATAs", + cell: (e) => ( +
+ {(e.iatas ?? []).map((i) => ( + {i.iata} + ))} +
+ ), + }, + ], [t]); + return (
- Repeaters & room servers out of sync · worst first + {t("clockDrift.caption")}
e.nodeId} selectedKey={null} onSelect={() => {}} - isLoading={clockDrift.isLoading} - emptyLabel={clockDrift.isError ? "Failed to load" : "No repeaters out of sync"} - defaultSort={{ header: "Drift", direction: "desc" }} + isLoading={loading} + emptyLabel={clockDrift.isError ? t("common.loadFailed") : t("clockDrift.empty")} + defaultSort={{ id: "drift", direction: "desc" }} />
); diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx index d79b7595..97c512fb 100644 --- a/src/features/stats/PathsTab.tsx +++ b/src/features/stats/PathsTab.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { formatCount } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { donutOption } from "./chartOptions"; @@ -10,66 +11,67 @@ import type { StatsRange } from "./types"; const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); export function PathsTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); const query = usePathStats(range), c = useChartColors(); const loading = query.isPending || query.isPlaceholderData; const data = loading || query.isError ? undefined : query.data; const hours = useMemo(() => pathHours(data), [data]); const categories = useMemo(() => [ - { name: "Hash paths", value: data?.hashed ?? 0, color: c.primary }, - { name: "Empty", value: data?.empty ?? 0, color: c.textDim }, - { name: "Trace", value: data?.trace ?? 0, color: c.warn }, - { name: "Unclassified", value: data?.unclassified ?? 0, color: c.secondary }, - ], [data, c]); + { name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.primary }, + { name: t("paths.categories.empty"), value: data?.empty ?? 0, color: c.textDim }, + { name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.warn }, + { name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.secondary }, + ], [data, c, t]); const charts = useMemo(() => { - const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: `${bin.bytes}-byte`, value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), "HASH PATHS"); - const coverage = donutOption(categories, c, formatCount(data?.receptions ?? 0), "RECEPTIONS"); + const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: t("paths.hashWidth", { count: bin.bytes }), value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), t("paths.hashPathsCenter")); + const coverage = donutOption(categories, c, formatCount(data?.receptions ?? 0), t("paths.receptionsCenter")); return { - width: { ...width, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below." } } }, - coverage: { ...coverage, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside." } } }, - lengths: pathLengthOption(data?.pathLengths ?? [], c), trend: pathTrendOption(hours, c), + width: { ...width, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: t("paths.widthDescription") } } }, + coverage: { ...coverage, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: t("paths.coverageDescription") } } }, + lengths: pathLengthOption(data?.pathLengths ?? [], c, t), trend: pathTrendOption(hours, c, t), }; - }, [data, c, categories, hours]); + }, [data, c, categories, hours, t]); const state = { isLoading: loading, isError: query.isError }; const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0; const largest = data?.pathLengths.at(-1)?.entries; return
-

Paths & Hashes

How received packets carry route hashes.

- +

{t("stats.tabs.paths")}

{t("paths.subtitle")}

+
- {query.isError &&

Could not load path data. Try refreshing or choosing a shorter time period.

} + {query.isError &&

{t("paths.error")}

}
- - - - + + + +
-

Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.

- {data &&

Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.

} +

{t("paths.measurement")}

+ {data &&

{t("paths.window", { since: utc(data.since), until: utc(data.until) })}

}
- - + +
- +
- - - {!data ?

{query.isError ? "Data unavailable" : "Loading paths…"}

: !data.receptions ?

No retained receptions in this window.

:
{o.pathLength.hopCount} {o.pathBytes ? ( - + ) : ( — )} diff --git a/tests/features/packets/ObservationTable.test.tsx b/tests/features/packets/ObservationTable.test.tsx index 3fbd120c..d82d95ff 100644 --- a/tests/features/packets/ObservationTable.test.tsx +++ b/tests/features/packets/ObservationTable.test.tsx @@ -89,4 +89,11 @@ describe("ObservationTable", () => { const cell = within(row).getAllByRole("cell")[3]!; expect(cell.className).toContain("text-danger"); }); + + it("colors a null SNR as dim", () => { + render( {}} />); + const row = screen.getAllByRole("row")[1]!; + const cell = within(row).getAllByRole("cell")[3]!; + expect(cell.className).toContain("text-text-dim"); + }); }); From f400dc5f762461adcd7490435cf8f96e7b7008bd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:37:18 -0400 Subject: [PATCH 070/163] Add the expanded packet region with skeleton, error and empty states --- src/features/packets/PacketExpansion.tsx | 76 ++++++++++ .../features/packets/PacketExpansion.test.tsx | 131 ++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 src/features/packets/PacketExpansion.tsx create mode 100644 tests/features/packets/PacketExpansion.test.tsx diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx new file mode 100644 index 00000000..ee196a65 --- /dev/null +++ b/src/features/packets/PacketExpansion.tsx @@ -0,0 +1,76 @@ +import type { PacketSummary } from "../../types/api"; +import { formatPropagation } from "../../lib/formatters"; +import { Timestamp } from "../../components/Timestamp"; +import { usePacketDetail } from "./usePacketDetail"; +import { ObservationTable } from "./ObservationTable"; + +// Roughly what fits the scroll cap; observations are unbounded server-side. +const SKELETON_ROW_CAP = 12; + +const ACTION_BUTTON_CLASS = + "border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; + +interface Props { + packet: PacketSummary; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Expanded region under a packet row: a summary-driven timing strip (instant, no fetch wait) plus +// the per-observer table, which does wait on usePacketDetail. +export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) { + const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash); + // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is + // already in ms for formatPropagation -- no *1000 here. + const spread = packet.lastHeardAt - packet.firstHeardAt; + const ready = !isLoading && !isError; + // The summary already knows the count is zero, so skip the fetch-driven states entirely rather + // than showing a blank (0-row) skeleton while it loads. + const noObservations = packet.observationCount === 0; + const emptyState =
No observations
; + + return ( +
+
+ first + last + spread {formatPropagation(spread)} + + +
+ +
+ {noObservations ? ( + emptyState + ) : isLoading ? ( +
+ {Array.from({ length: Math.min(packet.observationCount, SKELETON_ROW_CAP) }).map((_, i) => ( +
+ ))} +
+ ) : isError ? ( +
+ Failed to load observations + +
+ ) : data && data.observations.length === 0 ? ( + emptyState + ) : data ? ( + + ) : null} +
+
+ ); +} diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx new file mode 100644 index 00000000..b1b9074c --- /dev/null +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; +import type { PacketSummary, Observation } from "../../../src/types/api"; + +const usePacketDetail = vi.fn(); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (h: string | null) => usePacketDetail(h), +})); + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 3, ...over, +}); + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +const props = { + packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {}, + selectedObservationId: null, onSelectObservation: () => {}, +}; + +beforeEach(() => usePacketDetail.mockReset()); + +describe("PacketExpansion", () => { + it("shows one skeleton row per expected observation while loading", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(3); + }); + + it("caps skeleton rows at the scroll cap for a large observation count", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getAllByTestId("observation-skeleton")).toHaveLength(12); + }); + + it("shows an error line with retry on failure", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByText("Failed to load observations")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + }); + + it("calls refetch when retry is clicked", () => { + const refetch = vi.fn(); + usePacketDetail.mockReturnValue({ isError: true, refetch }); + render(); + fireEvent.click(screen.getByRole("button", { name: /retry/i })); + expect(refetch).toHaveBeenCalled(); + }); + + it("shows an empty state when the packet has no observations", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + render(); + expect(screen.getByText("No observations")).toBeInTheDocument(); + }); + + it("shows the empty state immediately when the summary count is zero, without a skeleton", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText("No observations")).toBeInTheDocument(); + expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument(); + }); + + it("renders the timing strip from the summary without waiting for the fetch", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText(/spread/i)).toBeInTheDocument(); + }); + + it("formats the spread as first/last converted from milliseconds, not re-scaled", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByText("spread 2.500s")).toBeInTheDocument(); + }); + + it("renders the observation table once data resolves", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } }); + render(); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("wires selectedObservationId and onSelectObservation through to the observation table", () => { + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } }); + render(); + const rows = screen.getAllByRole("row").slice(1); + expect(rows[1]).toHaveAttribute("aria-selected", "true"); + fireEvent.click(screen.getByText("Observer 1")); + expect(onSelectObservation).toHaveBeenCalledWith(1); + }); + + it("disables both action buttons while loading", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + }); + + it("disables both action buttons on error", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled(); + expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); + }); + + it("enables both action buttons once the fetch resolves", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled(); + expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); + }); + + it("calls onOpenAnalyzer and onViewPath when their buttons are clicked", () => { + const onOpenAnalyzer = vi.fn(); + const onViewPath = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + render(); + fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); + fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); + expect(onViewPath).toHaveBeenCalledTimes(1); + }); +}); From 64b822f4fa9fd49b414ec7aabd2c5d79acbf57f8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 19:43:58 -0400 Subject: [PATCH 071/163] Reorder PacketExpansion to show errors before empty state --- src/features/packets/PacketExpansion.tsx | 16 ++++++++-------- tests/features/packets/PacketExpansion.test.tsx | 8 ++++++++ 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index ee196a65..a4128705 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -46,7 +46,14 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
- {noObservations ? ( + {isError ? ( +
+ Failed to load observations + +
+ ) : noObservations ? ( emptyState ) : isLoading ? (
@@ -58,13 +65,6 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb /> ))}
- ) : isError ? ( -
- Failed to load observations - -
) : data && data.observations.length === 0 ? ( emptyState ) : data ? ( diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index b1b9074c..db7a6cfe 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -47,6 +47,14 @@ describe("PacketExpansion", () => { expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); }); + it("shows error state even when summary count is zero", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + expect(screen.getByText("Failed to load observations")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument(); + expect(screen.queryByText("No observations")).not.toBeInTheDocument(); + }); + it("calls refetch when retry is clicked", () => { const refetch = vi.fn(); usePacketDetail.mockReturnValue({ isError: true, refetch }); From 9582aaaa09f2cb5d9cecd4b0bb74df4d661e29e9 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 20:04:36 -0400 Subject: [PATCH 072/163] Wire the packet table and expandable rows into the virtual list --- src/App.tsx | 20 +- src/features/packets/PacketExpansion.tsx | 2 +- src/features/packets/PacketList.tsx | 37 ++- src/features/packets/PacketVirtualList.tsx | 31 ++- tests/features/packets/PacketList.test.tsx | 174 +++++++++++--- .../packets/PacketVirtualList.test.tsx | 217 ++++++++++++++++++ 6 files changed, 435 insertions(+), 46 deletions(-) create mode 100644 tests/features/packets/PacketVirtualList.test.tsx diff --git a/src/App.tsx b/src/App.tsx index fd6cb68a..28c68b88 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -176,6 +176,12 @@ function AppInner() { setPathMapInitialKey(key); }, []); + // "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own + const handleViewPath = useCallback((detail: PacketDetail) => { + setPathMapDetail(detail); + setPathMapInitialKey(null); + }, []); + const handleAnalyze = useCallback((hash: string | null) => { setSelectedObservationId(null); setSearchParams((p) => { @@ -264,7 +270,15 @@ function AppInner() { }, []); const tabContent: Record = { - Packets: , + Packets: ( + + ), Nodes: , Observers: , Routes: , @@ -302,7 +316,7 @@ function AppInner() { onSelectObservation={setSelectedObservationId} onClose={() => handleAnalyze(null)} onViewNode={setOverlayNodeId} - onViewPath={() => { if (analyzerDetail) { setPathMapDetail(analyzerDetail); setPathMapInitialKey(null); } }} + onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( @@ -337,7 +351,7 @@ function AppInner() { handleTabChange("Observers"); setSelectedObserverId(observerId); }} - onViewPath={() => { if (overlayPacketDetail) { setPathMapDetail(overlayPacketDetail); setPathMapInitialKey(null); } }} + onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} inactive={!!pathMapDetail} /> )} diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index a4128705..49c0c055 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -32,7 +32,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb const emptyState =
No observations
; return ( -
+
first last diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index f33e32d5..0340b6bb 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,6 +1,8 @@ import { useState, useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; +import { usePacketDetail } from "./usePacketDetail"; import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters"; import { useScopes } from "../../hooks/useScopes"; import { useRegion } from "../../hooks/useRegion"; @@ -11,6 +13,8 @@ import { LoadingPill } from "../../components/LoadingPill"; import { SkeletonRows } from "../../components/SkeletonRows"; import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums"; import type { WsManager } from "../../api/ws-manager"; +import type { PacketDetail } from "../../types/api"; +import type { WsPacketObservation } from "../../types/ws"; // filter options and storage keys @@ -27,13 +31,16 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ( interface PacketListProps { wsManager: WsManager; onAnalyze: (hash: string | null) => void; + onViewPath: (detail: PacketDetail) => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; } // main packet view: filters, banner, virtual list -// onAnalyze isn't called here — it's retained for the row-expansion's future "Open analyzer" button -export function PacketList({ wsManager }: PacketListProps) { +export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservationId, onSelectObservation }: PacketListProps) { const [searchParams, setSearchParams] = useSearchParams(); + const queryClient = useQueryClient(); const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); // single-value selections go to the server so scrolling pages through matching history const serverFilter = useMemo(() => toServerFilter(filters), [filters]); @@ -81,7 +88,27 @@ export function PacketList({ wsManager }: PacketListProps) { }, { replace: true }); }, [expandedHash, setSearchParams]); - useWsPacketHandler(wsManager, handlePacketObservation); + // Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request. + const { data: expandedDetail } = usePacketDetail(expandedHash); + + const handleOpenAnalyzer = useCallback(() => { + if (expandedHash) onAnalyze(expandedHash); + }, [expandedHash, onAnalyze]); + + const handleViewPath = useCallback(() => { + if (expandedDetail) onViewPath(expandedDetail); + }, [expandedDetail, onViewPath]); + + // Refetch only the open row's detail, so its observation table keeps pace with the count ticking + // up beside it. Every other observation just lands in the list. + const handleObservation = useCallback((data: WsPacketObservation["data"]) => { + handlePacketObservation(data); + if (data.packetHash === expandedHash) { + queryClient.invalidateQueries({ queryKey: ["packet-detail", expandedHash] }); + } + }, [handlePacketObservation, expandedHash, queryClient]); + + useWsPacketHandler(wsManager, handleObservation); useWsLaggedHandler(wsManager, handleLagged); const bannerCount = isScrolledAway ? newPacketCount : 0; @@ -177,6 +204,10 @@ export function PacketList({ wsManager }: PacketListProps) { onAtTopChange={setIsAtTop} expandedHash={expandedHash} onToggleExpand={handleToggleExpand} + onOpenAnalyzer={handleOpenAnalyzer} + onViewPath={handleViewPath} + selectedObservationId={selectedObservationId} + onSelectObservation={onSelectObservation} /> )} void; expandedHash: string | null; onToggleExpand: (hash: string) => void; + // only the expanded row renders an expansion, so these need no hash argument + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; } // virtualized scroll list with fresh-item highlighting and infinite load @@ -31,6 +38,10 @@ export function PacketVirtualList({ onAtTopChange, expandedHash, onToggleExpand, + onOpenAnalyzer, + onViewPath, + selectedObservationId, + onSelectObservation, }: PacketVirtualListProps) { const parentRef = useRef(null); const freshHashes = useFreshHashes(packets); @@ -40,7 +51,7 @@ export function PacketVirtualList({ const virtualizer = useVirtualizer({ count: packets.length, getScrollElement: () => parentRef.current, - estimateSize: () => 64, // rough -- rows vary a lot when expanded, tanstack remeasures + estimateSize: () => 64, // a collapsed two-line row; expanded ones are remeasured overscan: 10, getItemKey: (index) => packets[index]?.packetHash ?? index, }); @@ -84,16 +95,19 @@ export function PacketVirtualList({ className="flex-1 overflow-y-auto px-4 pb-10" onScroll={handleScroll} > +
{virtualizer.getVirtualItems().map((virtualRow) => { const packet = packets[virtualRow.index]; if (!packet) return null; + const expanded = expandedHash === packet.packetHash; return (
- onToggleExpand(packet.packetHash)} /> + {expanded && ( + + )}
); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 175ef3a3..d704489e 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,9 +1,11 @@ import { describe, it, expect, vi, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; -import type { PacketSummary } from "../../../src/types/api"; +import type { PacketSummary, PacketDetail } from "../../../src/types/api"; +import type { WsPacketObservation } from "../../../src/types/ws"; const basePackets = () => ({ allPackets: [] as PacketSummary[], @@ -26,30 +28,45 @@ vi.mock("../../../src/features/packets/usePackets", () => ({ usePackets: (...args: unknown[]) => usePackets(...(args as [])), })); +const usePacketDetail = vi.fn(() => ({ data: undefined as PacketDetail | undefined })); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => usePacketDetail(hash as never), +})); + vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), })); +// capture the packet handler so tests can push a live observation through it +let packetHandler: ((data: WsPacketObservation["data"]) => void) | null = null; vi.mock("../../../src/hooks/useWsHandlers", () => ({ - useWsPacketHandler: () => {}, + useWsPacketHandler: (_manager: unknown, handler: (data: WsPacketObservation["data"]) => void) => { + packetHandler = handler; + }, useWsLaggedHandler: () => {}, })); -// the virtual list needs ResizeObserver in jsdom; stub it down to the expand wiring under test +// the virtual list needs ResizeObserver in jsdom; stub it down to the wiring under test vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ PacketVirtualList: ({ packets, expandedHash, onToggleExpand, + onOpenAnalyzer, + onViewPath, }: { packets: PacketSummary[]; expandedHash: string | null; onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; }) => (
{String(expandedHash)}
+ + {packets.map((p) => ( -
diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 0340b6bb..58d8a600 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo } from "react"; +import { useState, useCallback, useEffect, useMemo, useRef } from "react"; import { useSearchParams } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; @@ -79,6 +79,23 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati // ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1). const expandedHash = searchParams.get("hash"); + // A deep-linked ?hash that matches nothing once the first page has loaded is stale (bogus, or + // long-expired) — strip it so it doesn't linger forever. Only the hash present at mount is + // checked, once, so a user's own click-to-expand (always a packet already in allPackets) never + // trips this. Gated on isLoading so a slow first page can't strip a link before its packet arrives. + const [initialHash] = useState(() => searchParams.get("hash")); + const strippedInitialHashRef = useRef(false); + useEffect(() => { + if (strippedInitialHashRef.current || isLoading || !initialHash) return; + strippedInitialHashRef.current = true; + if (allPackets.some((p) => p.packetHash === initialHash)) return; + setSearchParams((p) => { + const n = new URLSearchParams(p); + if (n.get("hash") === initialHash) n.delete("hash"); + return n; + }, { replace: true }); + }, [isLoading, initialHash, allPackets, setSearchParams]); + const handleToggleExpand = useCallback((hash: string) => { const next = expandedHash === hash ? null : hash; setSearchParams((p) => { diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx new file mode 100644 index 00000000..4ac8c337 --- /dev/null +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { App } from "../src/App"; +import type { PacketSummary, PacketDetail } from "../src/types/api"; + +// Full-app wiring test for the App.tsx <-> PacketExpansion <-> PacketAnalyzerDrawer coupling. +// Everything below the tab shell is real; only the network boundary and the virtualizer (needs +// layout/ResizeObserver jsdom doesn't have) are faked. + +vi.mock("../src/api/ws-manager", () => { + class WsManager { + connect() {} + disconnect() {} + updateSubscription() {} + onPacketObservation() { return () => {}; } + onLagged() { return () => {}; } + onChannelMessage() { return () => {}; } + onObserverStatus() { return () => {}; } + onNodeUpdate() { return () => {}; } + onStatusChange() { return () => {}; } + getStatus() { return "disconnected"; } + getLastEventTimestamp() { return Date.now(); } + } + return { WsManager }; +}); + +vi.mock("../src/api/client", () => ({ + getRegions: async () => [], + getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }), + getIatas: async () => [], + getScopes: async () => [], +})); + +const packet: PacketSummary = { + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 3, +}; + +const detail = { + packetHash: "AA11", + header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 }, + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 3, + rawPayload: "", decrypted: false, + observations: [ + { id: 1, observerId: "obs1", observerName: "Observer One", iata: "YOW", heardAt: 1700000000000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + { id: 2, observerId: "obs2", observerName: "Observer Two", iata: "YVR", heardAt: 1700000001000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + { id: 3, observerId: "obs3", observerName: "Observer Three", iata: "YYZ", heardAt: 1700000002000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + ], +} as unknown as PacketDetail; + +vi.mock("../src/features/packets/usePackets", () => ({ + usePackets: () => ({ + allPackets: [packet], + observerOptions: [], + newPacketCount: 0, + acknowledgeNewPackets: () => {}, + fetchNextPage: () => {}, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + observersByHash: new Map(), + handlePacketObservation: () => {}, + handleLagged: () => {}, + laggedCount: 0, + dismissLagged: () => {}, + }), +})); + +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ + data: hash === "AA11" ? detail : undefined, + isLoading: false, + isError: false, + refetch: () => {}, + }), +})); + +interface MockVirtualListProps { + packets: PacketSummary[]; + expandedHash: string | null; + onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Stands in for the virtualizer while keeping the real PacketExpansion mounted, so the row-select +// -> Open analyzer path under test is genuine, not reimplemented in the test. +vi.mock("../src/features/packets/PacketVirtualList", async () => { + const { PacketExpansion } = await import("../src/features/packets/PacketExpansion"); + return { + PacketVirtualList: ({ packets, expandedHash, onToggleExpand, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: MockVirtualListProps) => ( +
+ {packets.map((p) => ( +
+ + {expandedHash === p.packetHash && ( + + )} +
+ ))} +
+ ), + }; +}); + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {} }); + window.history.pushState({}, "", "/?tab=Packets"); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("opening the analyzer from an expanded row", () => { + // Regression: handleAnalyze used to reset selectedObservationId on every open, so picking an + // observation inside the expanded row and then opening the analyzer landed on observations[0] + // instead of the one clicked. + it("keeps the observation selected in the expanded row", async () => { + render(); + + fireEvent.click(await screen.findByRole("button", { name: "AA11" })); + fireEvent.click(await screen.findByText("Observer Three")); + fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); + + const drawer = await screen.findByTestId("packet-analyzer-drawer"); + expect(within(drawer).getByText("Observer Three")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index db7a6cfe..b9edab0a 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -1,7 +1,8 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; -import type { PacketSummary, Observation } from "../../../src/types/api"; +import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; +import { PayloadType, RouteType } from "../../../src/types/enums"; const usePacketDetail = vi.fn(); vi.mock("../../../src/features/packets/usePacketDetail", () => ({ @@ -20,6 +21,19 @@ const obs = (id: number, over: Partial = {}): Observation => ({ sourceBroker: "b1", resolvedPath: [], ...over, }); +// minimal header so buildPacketPaths(data) (View path on map's hasPath check) doesn't crash on a +// partial detail fixture — any non-TRACE payload type does +const header = () => ({ raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.ADVERT, payloadTypeName: "ADVERT", payloadVersion: 1 }); + +const resolvedHop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); + +// a detail with a real drawable path (>=2 located hops), for the "hasPath" enabled cases +const detailWithPath = (): PacketDetail => ({ + packetHash: "AA11", + header: header(), + observations: [obs(1, { resolvedPath: [resolvedHop("a", -79, 43), resolvedHop("b", -75, 45)] })], +} as unknown as PacketDetail); + const props = { packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {}, selectedObservationId: null, onSelectObservation: () => {}, @@ -64,7 +78,7 @@ describe("PacketExpansion", () => { }); it("shows an empty state when the packet has no observations", () => { - usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [] } }); render(); expect(screen.getByText("No observations")).toBeInTheDocument(); }); @@ -89,7 +103,7 @@ describe("PacketExpansion", () => { }); it("renders the observation table once data resolves", () => { - usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } }); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1), obs(2)] } }); render(); expect(screen.getByText("Observer 1")).toBeInTheDocument(); expect(screen.getByText("Observer 2")).toBeInTheDocument(); @@ -97,7 +111,7 @@ describe("PacketExpansion", () => { it("wires selectedObservationId and onSelectObservation through to the observation table", () => { const onSelectObservation = vi.fn(); - usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [obs(1), obs(2)] } }); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1), obs(2)] } }); render(); const rows = screen.getAllByRole("row").slice(1); expect(rows[1]).toHaveAttribute("aria-selected", "true"); @@ -119,17 +133,26 @@ describe("PacketExpansion", () => { expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); }); - it("enables both action buttons once the fetch resolves", () => { - usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + it("enables both action buttons once the fetch resolves with a drawable path", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled(); expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); }); + it("disables View path on map when the loaded detail has no resolvable path", () => { + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1)] } }); + render(); + expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled(); + const viewPathBtn = screen.getByRole("button", { name: "View path on map" }); + expect(viewPathBtn).toBeDisabled(); + expect(viewPathBtn).toHaveAttribute("title", "No resolved path to map"); + }); + it("calls onOpenAnalyzer and onViewPath when their buttons are clicked", () => { const onOpenAnalyzer = vi.fn(); const onViewPath = vi.fn(); - usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); fireEvent.click(screen.getByRole("button", { name: "View path on map" })); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index d704489e..274367e2 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; @@ -100,6 +100,11 @@ const observation = (hash: string): WsPacketObservation["data"] => ({ }, }); +function LocationProbe() { + const location = useLocation(); + return
{location.search}
; +} + function renderList(url = "/", props: Partial[0]> = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidate = vi.spyOn(queryClient, "invalidateQueries"); @@ -107,7 +112,7 @@ function renderList(url = "/", props: Partial[0]> const onViewPath = props.onViewPath ?? vi.fn(); const onSelectObservation = props.onSelectObservation ?? vi.fn(); - render( + const tree = ( [0]> selectedObservationId={null} onSelectObservation={onSelectObservation} /> + - , + ); - return { onAnalyze, onViewPath, onSelectObservation, invalidate }; + const utils = render(tree); + + // MemoryRouter only reads initialEntries on its first mount, so rerendering with the identical + // element (same position in the tree) keeps whatever location the component has navigated to. + return { onAnalyze, onViewPath, onSelectObservation, invalidate, rerender: () => utils.rerender(tree) }; } describe("PacketList server filter wiring", () => { @@ -270,3 +280,38 @@ describe("PacketList live observation invalidation", () => { expect(invalidate).not.toHaveBeenCalled(); }); }); + +describe("PacketList stale ?hash strip", () => { + afterEach(() => { + usePackets.mockImplementation(basePackets); + }); + + it("strips a ?hash matching no loaded packet once the first page has loaded", async () => { + usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("AA11")] })); + + renderList("/?tab=Packets&hash=BOGUS"); + + await waitFor(() => expect(screen.getByTestId("search").textContent).not.toContain("hash=")); + }); + + it("does not strip while the first page is still loading", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true, allPackets: [] })); + + const { rerender } = renderList("/?tab=Packets&hash=BOGUS"); + expect(screen.getByTestId("search").textContent).toContain("hash=BOGUS"); + + // the packet that matches the deep link arrives only after the first page resolves + usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("BOGUS")] })); + rerender(); + + expect(screen.getByTestId("search").textContent).toContain("hash=BOGUS"); + }); + + it("does not strip a ?hash that matches a loaded packet", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("AA11")] })); + + renderList("/?tab=Packets&hash=AA11"); + + expect(screen.getByTestId("search").textContent).toContain("hash=AA11"); + }); +}); diff --git a/tests/features/packets/PacketVirtualList.test.tsx b/tests/features/packets/PacketVirtualList.test.tsx index 60339ece..449afa76 100644 --- a/tests/features/packets/PacketVirtualList.test.tsx +++ b/tests/features/packets/PacketVirtualList.test.tsx @@ -4,7 +4,7 @@ import { PacketVirtualList } from "../../../src/features/packets/PacketVirtualLi import type { PacketSummary } from "../../../src/types/api"; // PacketExpansion fetches through usePacketDetail; stub it so the list renders without a query client. -const usePacketDetail = vi.fn(() => ({ data: { packetHash: "AA11", observations: [] } })); +const usePacketDetail = vi.fn(() => ({ data: { packetHash: "AA11", header: { payloadType: 1 }, observations: [] } })); vi.mock("../../../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (h: string | null) => usePacketDetail(h), })); @@ -90,7 +90,7 @@ function setScrollMetrics(el: HTMLElement, { scrollHeight, clientHeight, scrollT beforeEach(() => { observers.length = 0; - usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", observations: [] } }); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: { payloadType: 1 }, observations: [] } }); }); describe("PacketVirtualList expansion", () => { @@ -130,6 +130,14 @@ describe("PacketVirtualList expansion", () => { }); it("forwards the expansion's actions", () => { + const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); + usePacketDetail.mockReturnValue({ + data: { + packetHash: "AA11", + header: { payloadType: 1 }, + observations: [{ id: 1, observerId: "o1", iata: "YOW", heardAt: 0, sourceBroker: "b", pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)] }], + }, + }); const handlers = makeHandlers(); render(); From 663eeef775f1269a51b69552c3e6db07c35f579e Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 20:41:57 -0400 Subject: [PATCH 074/163] Stop stripping ?hash on mount, it breaks deep links to older packets The analyzer drawer and path-map restore both read the live ?hash and fetch by hash directly, independent of whether the packet is in the loaded list. Deleting the param once the first page settles without a match unmounts the drawer and can drop path-map restores, for any shared link more than a couple of minutes old. --- src/App.tsx | 3 +- src/features/packets/PacketList.tsx | 19 +------- tests/features/packets/PacketList.test.tsx | 55 ++-------------------- 3 files changed, 7 insertions(+), 70 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 42078305..08cd63a3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -183,8 +183,7 @@ function AppInner() { }, []); const handleAnalyze = useCallback((hash: string | null) => { - // No reset here: observation ids are globally unique, so a stale one from another packet - // can't accidentally match — this lets a pick made inside an expanded row survive into the drawer. + // No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer. setSearchParams((p) => { const n = new URLSearchParams(p); if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); } diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 58d8a600..0340b6bb 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useEffect, useMemo, useRef } from "react"; +import { useState, useCallback, useEffect, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; @@ -79,23 +79,6 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati // ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1). const expandedHash = searchParams.get("hash"); - // A deep-linked ?hash that matches nothing once the first page has loaded is stale (bogus, or - // long-expired) — strip it so it doesn't linger forever. Only the hash present at mount is - // checked, once, so a user's own click-to-expand (always a packet already in allPackets) never - // trips this. Gated on isLoading so a slow first page can't strip a link before its packet arrives. - const [initialHash] = useState(() => searchParams.get("hash")); - const strippedInitialHashRef = useRef(false); - useEffect(() => { - if (strippedInitialHashRef.current || isLoading || !initialHash) return; - strippedInitialHashRef.current = true; - if (allPackets.some((p) => p.packetHash === initialHash)) return; - setSearchParams((p) => { - const n = new URLSearchParams(p); - if (n.get("hash") === initialHash) n.delete("hash"); - return n; - }, { replace: true }); - }, [isLoading, initialHash, allPackets, setSearchParams]); - const handleToggleExpand = useCallback((hash: string) => { const next = expandedHash === hash ? null : hash; setSearchParams((p) => { diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 274367e2..d704489e 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,6 +1,6 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; import type { WsManager } from "../../../src/api/ws-manager"; @@ -100,11 +100,6 @@ const observation = (hash: string): WsPacketObservation["data"] => ({ }, }); -function LocationProbe() { - const location = useLocation(); - return
{location.search}
; -} - function renderList(url = "/", props: Partial[0]> = {}) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const invalidate = vi.spyOn(queryClient, "invalidateQueries"); @@ -112,7 +107,7 @@ function renderList(url = "/", props: Partial[0]> const onViewPath = props.onViewPath ?? vi.fn(); const onSelectObservation = props.onSelectObservation ?? vi.fn(); - const tree = ( + render( [0]> selectedObservationId={null} onSelectObservation={onSelectObservation} /> - - + , ); - const utils = render(tree); - - // MemoryRouter only reads initialEntries on its first mount, so rerendering with the identical - // element (same position in the tree) keeps whatever location the component has navigated to. - return { onAnalyze, onViewPath, onSelectObservation, invalidate, rerender: () => utils.rerender(tree) }; + return { onAnalyze, onViewPath, onSelectObservation, invalidate }; } describe("PacketList server filter wiring", () => { @@ -280,38 +270,3 @@ describe("PacketList live observation invalidation", () => { expect(invalidate).not.toHaveBeenCalled(); }); }); - -describe("PacketList stale ?hash strip", () => { - afterEach(() => { - usePackets.mockImplementation(basePackets); - }); - - it("strips a ?hash matching no loaded packet once the first page has loaded", async () => { - usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("AA11")] })); - - renderList("/?tab=Packets&hash=BOGUS"); - - await waitFor(() => expect(screen.getByTestId("search").textContent).not.toContain("hash=")); - }); - - it("does not strip while the first page is still loading", () => { - usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true, allPackets: [] })); - - const { rerender } = renderList("/?tab=Packets&hash=BOGUS"); - expect(screen.getByTestId("search").textContent).toContain("hash=BOGUS"); - - // the packet that matches the deep link arrives only after the first page resolves - usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("BOGUS")] })); - rerender(); - - expect(screen.getByTestId("search").textContent).toContain("hash=BOGUS"); - }); - - it("does not strip a ?hash that matches a loaded packet", () => { - usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: false, allPackets: [packet("AA11")] })); - - renderList("/?tab=Packets&hash=AA11"); - - expect(screen.getByTestId("search").textContent).toContain("hash=AA11"); - }); -}); From 907b42e7be4359443aa2573ddd3c7c467e2b45b4 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 20:47:31 -0400 Subject: [PATCH 075/163] Keep the packet card layout below md --- src/features/packets/PacketVirtualList.tsx | 24 ++++++--- .../packets/PacketVirtualList.test.tsx | 53 +++++++++++++++++++ 2 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 5b779969..8eed3e22 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -3,8 +3,10 @@ import { useVirtualizer } from "@tanstack/react-virtual"; import type { PacketSummary } from "../../types/api"; import { PacketTableHeader } from "./PacketTableHeader"; import { PacketTableRow } from "./PacketTableRow"; +import { PacketRow } from "./PacketRow"; import { PacketExpansion } from "./PacketExpansion"; import { useFreshHashes } from "./useFreshHashes"; +import { useIsMobile } from "../../hooks/useMediaQuery"; import { SCROLL_TOP_THRESHOLD_PX, SCROLL_BOTTOM_THRESHOLD_PX, @@ -45,6 +47,7 @@ export function PacketVirtualList({ }: PacketVirtualListProps) { const parentRef = useRef(null); const freshHashes = useFreshHashes(packets); + const isMobile = useIsMobile(); const atTopRef = useRef(true); const prevFirstKeyRef = useRef(packets[0]?.packetHash); @@ -118,12 +121,21 @@ export function PacketVirtualList({ }} >
- onToggleExpand(packet.packetHash)} - /> + {isMobile ? ( + onToggleExpand(packet.packetHash)} + /> + ) : ( + onToggleExpand(packet.packetHash)} + /> + )} {expanded && ( { expect(handlers.fetchNextPage).not.toHaveBeenCalled(); }); }); + +// mirrors DataTable.test.tsx's mobile stub, keeping the max-width query the only configurable one +// so hover-driven components (e.g. Tooltip, used by PacketRow) keep their default hover behaviour +function setMobile(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: /max-width/.test(query) ? matches : /hover/.test(query), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +describe("PacketVirtualList responsive row", () => { + afterEach(() => { + setMobile(false); // back to the desktop default so later tests in this file aren't affected + }); + + it("renders the card row below md", () => { + setMobile(true); + const handlers = makeHandlers(); + const { container } = render( + , + ); + + // PacketRow has no button role; PacketTableRow's toggle is a real +
diff --git a/src/features/packets/usePacketDetail.ts b/src/features/packets/usePacketDetail.ts index 4e11651b..fa3316d9 100644 --- a/src/features/packets/usePacketDetail.ts +++ b/src/features/packets/usePacketDetail.ts @@ -3,7 +3,9 @@ import { getPacketDetail } from "../../api/client"; import type { PacketDetail } from "../../types/api"; // One query per hash shared by the expanded row, the analyzer drawer and the overlay — TanStack -// dedupes, so a row expanded under an open drawer costs a single request. +// dedupes, so a row expanded under an open drawer costs a single request. The short staleTime is +// deliberate: observations keep accruing, so reopening should show them rather than the snapshot +// frozen at first open. export function usePacketDetail(hash: string | null) { return useQuery({ queryKey: ["packet-detail", hash], diff --git a/src/types/api.ts b/src/types/api.ts index 28d09130..f50ac808 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -18,7 +18,7 @@ export interface LatestObserver { pathBytes?: string; resolvedSource?: ResolvedHop; resolvedDestination?: ResolvedHop; - // per-hop resolved path; WS-only, and only when the connection opts into configure{resolvePath}. + // per-hop resolved path, for the REST list once the backend fills it in — nothing populates it today. resolvedPath?: ResolvedHop[]; } diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx new file mode 100644 index 00000000..83dc1679 --- /dev/null +++ b/tests/App.packetUrlContract.test.tsx @@ -0,0 +1,189 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { App } from "../src/App"; +import type { PacketSummary, PacketDetail } from "../src/types/api"; + +// The Packets tab's URL contract, exercised through a real App: ?hash expands a row, ?analyze=1 adds +// the drawer on top of it, and a mobile tab change leaves neither behind. Only the network boundary +// and the virtualizer (needs layout/ResizeObserver jsdom doesn't have) are faked. + +vi.mock("../src/api/ws-manager", () => { + class WsManager { + connect() {} + disconnect() {} + updateSubscription() {} + onPacketObservation() { return () => {}; } + onLagged() { return () => {}; } + onChannelMessage() { return () => {}; } + onObserverStatus() { return () => {}; } + onNodeUpdate() { return () => {}; } + onStatusChange() { return () => {}; } + getStatus() { return "disconnected"; } + getLastEventTimestamp() { return Date.now(); } + } + return { WsManager }; +}); + +vi.mock("../src/api/client", () => ({ + getRegions: async () => [], + getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }), + getIatas: async () => [], + getScopes: async () => [], + getChannels: async () => [], + getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), +})); + +const packet: PacketSummary = { + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, observationCount: 1, +}; + +const detail = { + packetHash: "AA11", + header: { raw: "12", routeType: 1, routeTypeName: "FLOOD", payloadType: 1, payloadTypeName: "ADVERT", payloadVersion: 1 }, + firstHeardAt: 1700000000000, lastHeardAt: 1700000002000, firstToLastMs: 2000, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [ + { id: 1, observerId: "obs1", observerName: "Observer One", iata: "YOW", heardAt: 1700000000000, sourceBroker: "b1", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, resolvedPath: [] }, + ], +} as unknown as PacketDetail; + +vi.mock("../src/features/packets/usePackets", () => ({ + usePackets: () => ({ + allPackets: [packet], + observerOptions: [], + newPacketCount: 0, + acknowledgeNewPackets: () => {}, + fetchNextPage: () => {}, + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isError: false, + observersByHash: new Map(), + handlePacketObservation: () => {}, + handleLagged: () => {}, + laggedCount: 0, + dismissLagged: () => {}, + }), +})); + +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ + data: hash === "AA11" ? detail : undefined, + isLoading: false, + isError: false, + refetch: () => {}, + }), +})); + +interface MockVirtualListProps { + packets: PacketSummary[]; + expandedHash: string | null; + onToggleExpand: (hash: string) => void; + onOpenAnalyzer: () => void; + onViewPath: () => void; + selectedObservationId: number | null; + onSelectObservation: (id: number) => void; +} + +// Stands in for the virtualizer while keeping the real PacketExpansion mounted, so what ?hash +// expands is the genuine component and not a test stub. +vi.mock("../src/features/packets/PacketVirtualList", async () => { + const { PacketExpansion } = await import("../src/features/packets/PacketExpansion"); + return { + PacketVirtualList: ({ packets, expandedHash, onToggleExpand, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: MockVirtualListProps) => ( +
+ {packets.map((p) => ( +
+ + {expandedHash === p.packetHash && ( + + )} +
+ ))} +
+ ), + }; +}); + +function setMobile(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: /max-width/.test(query) ? matches : /hover/.test(query), + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {}, clear: () => {} }); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("Packets deep links", () => { + it("restores the expanded row and the drawer from ?hash&analyze=1", async () => { + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + render(); + + expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument(); + expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + }); + + it("expands the row without the drawer from ?hash alone", async () => { + window.history.pushState({}, "", "/?tab=Packets&hash=AA11"); + render(); + + expect(await screen.findByTestId("packet-expansion")).toBeInTheDocument(); + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + }); + + it("opens nothing when ?analyze=1 arrives without a hash", async () => { + window.history.pushState({}, "", "/?tab=Packets&analyze=1"); + render(); + + expect(await screen.findByRole("button", { name: "AA11" })).toBeInTheDocument(); + expect(screen.queryByTestId("packet-expansion")).not.toBeInTheDocument(); + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + }); +}); + +describe("leaving the Packets tab", () => { + // The drawer is full-screen below md, and it renders on Channels too — so a mobile tab change has + // to drop ?analyze or the analyzer covers the tab the user just asked for. + it("closes the analyzer on mobile", async () => { + setMobile(true); + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + render(); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("tab", { name: "Channels" })[0]!); + + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + }); + + it("keeps the analyzer open on desktop", async () => { + setMobile(false); + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); + render(); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + + fireEvent.click(screen.getAllByRole("tab", { name: "Channels" })[0]!); + + expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index c626adc8..17595f8a 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -25,6 +25,7 @@ afterEach(() => vi.unstubAllGlobals()); const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); const detail = { + packetHash: "aabbccdd", header: { payloadType: PayloadType.TEXT, routeType: 1 }, observations: [ { id: 1, observerId: "obs-alpha", observerName: "Alpha", iata: "YYZ", heardAt: 0, sourceBroker: "b", pathLength: { raw: "", hashSize: 1, hopCount: 2 }, resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], propagationTimeMs: 100 }, @@ -74,4 +75,35 @@ describe("PacketPathMapModal", () => { render( {}} />); expect(screen.getByRole("button", { name: "Copy path link" })).toBeInTheDocument(); }); + + describe("copy path link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies the selected path and strips the analyzer", () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=aabb&analyze=1"); + render( {}} />); + fireEvent.click(screen.getByText("Bravo")); + + fireEvent.click(screen.getByRole("button", { name: "Copy path link" })); + + const copied = new URL(writeText.mock.calls[0]![0] as string); + expect(copied.searchParams.get("tab")).toBe("Packets"); + expect(copied.searchParams.get("hash")).toBe(detail.packetHash); + expect(copied.searchParams.get("path")).toBe("obs-bravo"); + expect(copied.searchParams.has("analyze")).toBe(false); // path and analyze are exclusive + }); + + it("copies path=all when nothing is isolated", () => { + render( {}} />); + fireEvent.click(screen.getByRole("button", { name: "Copy path link" })); + expect(new URL(writeText.mock.calls[0]![0] as string).searchParams.get("path")).toBe("all"); + }); + }); }); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index c2fa0688..8dc1853d 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer"; @@ -42,6 +42,32 @@ function makeDetail(resolvedPath: unknown[]): PacketDetail { } as unknown as PacketDetail; } +describe("PacketAnalyzerDrawer copy link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies a link that reopens the drawer over the expanded row", () => { + render( + + {}} /> + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Copy packet link" })); + + const copied = new URL(writeText.mock.calls[0]![0] as string); + expect(copied.searchParams.get("tab")).toBe("Packets"); + expect(copied.searchParams.get("hash")).toBe("abcdef12"); + expect(copied.searchParams.get("analyze")).toBe("1"); // the drawer is part of the shared state + }); +}); + describe("PacketAnalyzerDrawer view-path button", () => { it("enables the button and calls onViewPath when a path is drawable", () => { const onViewPath = vi.fn(); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index b9edab0a..cbbf9c05 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; @@ -160,3 +160,28 @@ describe("PacketExpansion", () => { expect(onViewPath).toHaveBeenCalledTimes(1); }); }); + +describe("PacketExpansion copy link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies a link to the expanded row with the analyzer stripped", () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=AA11&analyze=1&iata=YVR"); + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy row link" })); + + const copied = new URL(writeText.mock.calls[0]![0] as string); + expect(copied.searchParams.get("tab")).toBe("Packets"); + expect(copied.searchParams.get("hash")).toBe("AA11"); + expect(copied.searchParams.has("analyze")).toBe(false); // the drawer must not tag along + expect(copied.searchParams.get("iata")).toBe("YVR"); // unrelated params survive + }); +}); From d1ba42e6921d4e44c1fa1ff1bf52be3676e0c4d8 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 21:30:55 -0400 Subject: [PATCH 078/163] Drop a stale doc pointer from two map comments --- src/features/map/MapView.tsx | 2 +- src/features/map/map-url.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 91e477b0..78710776 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -39,7 +39,7 @@ interface MapViewProps { export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProps) { // Deep-link params, read once at mount (like the region's ?iata seed). Each setting below is seeded // URL -> localStorage -> default; the URL wins for this session but is never written back to - // localStorage, so a shared link can't clobber the visitor's saved prefs. See docs/superpowers/specs. + // localStorage, so a shared link can't clobber the visitor's saved prefs. const [searchParams] = useSearchParams(); const [urlView] = useState(() => parseMapView(searchParams)); diff --git a/src/features/map/map-url.ts b/src/features/map/map-url.ts index db55f5e7..6f9a522f 100644 --- a/src/features/map/map-url.ts +++ b/src/features/map/map-url.ts @@ -1,6 +1,6 @@ // Deep-link map view <-> URL params. Pure and maplibre-free so it stays unit-testable; mirrors the // region-selection.ts pattern. Inbound parsing is lenient — any invalid/unknown value is dropped so a -// malformed link degrades to the normal view rather than breaking. See docs/superpowers/specs. +// malformed link degrades to the normal view rather than breaking. import { MAP_STYLES, type NeighborLinesMode } from "./types"; import { NODE_TYPE_NAMES } from "../../lib/node-types"; From 1f3fb5b7c932cf77439cb11ab308aa2eb5b3b6b2 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 22:06:05 -0400 Subject: [PATCH 079/163] Collapse the packet row to a single line with hops and endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The header and the rows are separate grids, so the shared track list can't use ch or auto — each resolved them differently and the columns drifted apart. Observer moves into the expansion to free the column. --- src/features/packets/PacketEndpoints.tsx | 47 ++++++++ src/features/packets/PacketExpansion.tsx | 7 ++ src/features/packets/PacketPathLine.tsx | 57 --------- src/features/packets/PacketTableHeader.tsx | 3 +- src/features/packets/PacketTableRow.tsx | 22 ++-- src/features/packets/PacketVirtualList.tsx | 3 +- src/features/packets/packet-grid.ts | 8 +- .../features/packets/PacketEndpoints.test.tsx | 67 ++++++++++ .../features/packets/PacketPathLine.test.tsx | 114 ------------------ .../packets/PacketTableHeader.test.tsx | 19 ++- .../features/packets/PacketTableRow.test.tsx | 67 +++++++--- 11 files changed, 210 insertions(+), 204 deletions(-) create mode 100644 src/features/packets/PacketEndpoints.tsx delete mode 100644 src/features/packets/PacketPathLine.tsx create mode 100644 tests/features/packets/PacketEndpoints.test.tsx delete mode 100644 tests/features/packets/PacketPathLine.test.tsx diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx new file mode 100644 index 00000000..33e5822d --- /dev/null +++ b/src/features/packets/PacketEndpoints.tsx @@ -0,0 +1,47 @@ +import type { PacketSummary } from "../../types/api"; +import type { PathConfidence } from "../../types/enums"; +import { buildPathSummary, type PathChip } from "./path-summary"; + +// Same three-state vocabulary PathData uses in the analyzer. +const CONFIDENCE_CLASSES: Record = { + high: "bg-green/8 text-green", + ambiguous: "bg-warn/8 text-warn", + none: "bg-text-muted/8 text-text-dim", +}; + +function Chip({ chip }: { chip: PathChip }) { + if (chip.kind === "hex") { + return {chip.label}; + } + if (chip.kind === "unresolved-run") { + return ( + + {chip.count === 1 ? "?" : `?×${chip.count}`} + + ); + } + return ( + + {chip.label} + + ); +} + +const Na = () => n/a; + +// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them +// nil on the REST list, so scrollback rows read n/a — as do payload types with no addressed +// endpoint at all (GRP_TXT/GRP_DATA/TRACE). +export function PacketEndpoints({ packet }: { packet: PacketSummary }) { + const { source, destination } = buildPathSummary(packet); + // One n/a for the pair reads better than "n/a → n/a" on every historical row. + if (!source && !destination) return ; + + return ( + + {source ? : } + → + {destination ? : } + + ); +} diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index 30bb1b78..d609c88b 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -25,6 +25,7 @@ interface Props { // the per-observer table, which does wait on usePacketDetail. export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) { const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash); + const observer = packet.latestObserver; // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is // already in ms for formatPropagation -- no *1000 here. const spread = packet.lastHeardAt - packet.firstHeardAt; @@ -43,6 +44,12 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb return (
+ + observer{" "} + {observer + ? {observer.displayName ?? observer.id.slice(0, 8)} + : n/a} + first last spread {formatPropagation(spread)} diff --git a/src/features/packets/PacketPathLine.tsx b/src/features/packets/PacketPathLine.tsx deleted file mode 100644 index 427dde61..00000000 --- a/src/features/packets/PacketPathLine.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { PacketSummary } from "../../types/api"; -import type { PathConfidence } from "../../types/enums"; -import { buildPathSummary, type PathChip } from "./path-summary"; - -// Same three-state vocabulary PathData uses in the analyzer. -const CONFIDENCE_CLASSES: Record = { - high: "bg-green/8 text-green", - ambiguous: "bg-warn/8 text-warn", - none: "bg-text-muted/8 text-text-dim", -}; - -function Chip({ chip }: { chip: PathChip }) { - if (chip.kind === "hex") { - return {chip.label}; - } - if (chip.kind === "unresolved-run") { - return ( - - {chip.count === 1 ? "?" : `?×${chip.count}`} - - ); - } - return ( - - {chip.label} - - ); -} - -// Line 2 of a packet row: always the latest observation's path, never the selected one. -export function PacketPathLine({ packet }: { packet: PacketSummary }) { - const summary = buildPathSummary(packet); - - if (summary.isNa) { - return n/a; - } - - return ( -
- latest - {summary.hopLabel} - {summary.chips.map((chip, i) => )} - {summary.overflow > 0 && ( - - +{summary.overflow} more - - )} - {(summary.source || summary.destination) && ( - - {summary.source ? : n/a} - → - {summary.destination ? : n/a} - - )} -
- ); -} diff --git a/src/features/packets/PacketTableHeader.tsx b/src/features/packets/PacketTableHeader.tsx index a9bb5682..5366f45d 100644 --- a/src/features/packets/PacketTableHeader.tsx +++ b/src/features/packets/PacketTableHeader.tsx @@ -12,7 +12,8 @@ export function PacketTableHeader() { Type Route Obs - Observer + Hops + Src → Dst IATA Age
diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 5433e94b..ff67b15b 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -6,7 +6,7 @@ import { payloadTypeVariant } from "../../components/badge-utils"; import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums"; import type { PacketSummary } from "../../types/api"; import { GRID_TEMPLATE } from "./packet-grid"; -import { PacketPathLine } from "./PacketPathLine"; +import { PacketEndpoints } from "./PacketEndpoints"; interface PacketTableRowProps { packet: PacketSummary; @@ -15,10 +15,11 @@ interface PacketTableRowProps { onToggle: () => void; } -// two-line table row: line 1 is the identity grid (shares GRID_TEMPLATE with the header), line 2 -// is the latest path. The whole row is the expansion click target. +// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the +// expansion instead, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { - const observer = packet.latestObserver; + // ?? not ||, so a legitimate 0-hop direct packet still shows its count + const hopCount = packet.latestObserver?.pathLength?.hopCount; return (
{packet.scope}} ×{packet.observationCount} - - {observer ? (observer.displayName ?? observer.id.slice(0, 8)) : n/a} + + {hopCount ?? n/a} + + + - {observer?.iata ?? n/a} + {packet.latestObserver?.iata ?? n/a} - -
- -
); } diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 8eed3e22..2cf218bc 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -54,7 +54,8 @@ export function PacketVirtualList({ const virtualizer = useVirtualizer({ count: packets.length, getScrollElement: () => parentRef.current, - estimateSize: () => 64, // a collapsed two-line row; expanded ones are remeasured + // a collapsed row: one grid line on desktop, a taller card below md. Expanded rows are remeasured. + estimateSize: () => (isMobile ? 64 : 43), overscan: 10, getItemKey: (index) => packets[index]?.packetHash ?? index, }); diff --git a/src/features/packets/packet-grid.ts b/src/features/packets/packet-grid.ts index 0a5aec0e..2845d8fd 100644 --- a/src/features/packets/packet-grid.ts +++ b/src/features/packets/packet-grid.ts @@ -1,3 +1,5 @@ -// One track list shared by the sticky header and every row, so columns stay aligned. Line 2 spans -// all of it via grid-column: 1 / -1. -export const GRID_TEMPLATE = "1.25rem minmax(7ch,auto) 6rem 5rem 3rem minmax(8rem,1fr) 3.5rem 5rem"; +// One track list shared by the sticky header and every row, so columns stay aligned. Keep every +// track font- and content-independent: the header and the rows are separate grids, so a `ch` track +// resolves against each one's own font size (9px vs 11px) and an `auto` track against its own text +// ("HASH" vs "4AE77F09") — either silently drifts the two apart. +export const GRID_TEMPLATE = "1.25rem 5rem 6rem 5rem 3rem 3rem minmax(6rem,1fr) 3.5rem 5rem"; diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx new file mode 100644 index 00000000..037489cc --- /dev/null +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -0,0 +1,67 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; +import type { LatestObserver, PacketSummary } from "../../../src/types/api"; + +const pkt = (observer?: LatestObserver): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, latestObserver: observer, +}); + +const obs = (over: Partial = {}): LatestObserver => ({ + id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over, +}); + +describe("PacketEndpoints", () => { + it("renders a single n/a when there is no observer at all", () => { + render(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + it("renders both endpoints with the arrow glyph between them", () => { + render(); + expect(screen.getByText("SrcNode")).toBeInTheDocument(); + expect(screen.getByText("DstNode")).toBeInTheDocument(); + expect(screen.getByText("→")).toBeInTheDocument(); + }); + + it("shows n/a for a missing endpoint while the present one still renders", () => { + render(); + expect(screen.getByText("SrcNode")).toBeInTheDocument(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + }); + + // The REST list leaves both nil, so this is the common scrollback case — one n/a, no arrow. + it("collapses to a single n/a when both endpoints are absent", () => { + render(); + expect(screen.getByText("n/a")).toBeInTheDocument(); + expect(screen.queryByText("→")).not.toBeInTheDocument(); + }); + + it("tints an ambiguous endpoint with the warn token", () => { + render(); + expect(screen.getByText("Raven").className).toContain("text-warn"); + }); + + it("tints a high-confidence endpoint with the green token", () => { + render(); + expect(screen.getByText("Falcon").className).toContain("text-green"); + }); + + it("renders a bare ? for an endpoint the backend could not resolve", () => { + render(); + expect(screen.getByText("?")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/PacketPathLine.test.tsx b/tests/features/packets/PacketPathLine.test.tsx deleted file mode 100644 index 0843834b..00000000 --- a/tests/features/packets/PacketPathLine.test.tsx +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; -import { PacketPathLine } from "../../../src/features/packets/PacketPathLine"; -import type { PacketSummary } from "../../../src/types/api"; - -const pkt = (over: Partial = {}): PacketSummary => ({ - packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", - routeType: 1, routeTypeName: "FLOOD", - firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, ...over, -}); - -describe("PacketPathLine", () => { - it("renders a single n/a when there is nothing to show", () => { - render(); - expect(screen.getByText("n/a")).toBeInTheDocument(); - }); - - it("renders hop label, hex chips and the overflow count", () => { - render(); - expect(screen.getByText("14 hops")).toBeInTheDocument(); - expect(screen.getByText("00")).toBeInTheDocument(); - expect(screen.getByText("+9 more")).toBeInTheDocument(); - }); - - it("tints an ambiguous hop with the warn token", () => { - render(); - expect(screen.getByText("Raven").className).toContain("text-warn"); - }); - - it("tints a high-confidence hop with the green token", () => { - render(); - expect(screen.getByText("Falcon").className).toContain("text-green"); - }); - - it("renders both endpoints with the arrow glyph between them", () => { - render(); - expect(screen.getByText("SrcNode")).toBeInTheDocument(); - expect(screen.getByText("DstNode")).toBeInTheDocument(); - expect(screen.getByText("→")).toBeInTheDocument(); - }); - - it("shows n/a for a missing endpoint while the present one still renders", () => { - render(); - expect(screen.getByText("SrcNode")).toBeInTheDocument(); - expect(screen.getByText("n/a")).toBeInTheDocument(); - }); - - it("omits the endpoint block entirely when both endpoints are absent", () => { - render(); - expect(screen.queryByText("→")).not.toBeInTheDocument(); - }); - - it("renders a bare ? for a single unresolved hop", () => { - render(); - expect(screen.getByText("?")).toBeInTheDocument(); - }); - - it("collapses a run of unresolved hops into a single ?×N chip", () => { - render(); - expect(screen.getByText("?×3")).toBeInTheDocument(); - }); -}); diff --git a/tests/features/packets/PacketTableHeader.test.tsx b/tests/features/packets/PacketTableHeader.test.tsx index 1b7ecadd..0f850439 100644 --- a/tests/features/packets/PacketTableHeader.test.tsx +++ b/tests/features/packets/PacketTableHeader.test.tsx @@ -6,11 +6,16 @@ import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid"; describe("PacketTableHeader", () => { it("declares every column heading", () => { render(); - for (const h of ["Hash", "Type", "Route", "Obs", "Observer", "IATA", "Age"]) { + for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Src → Dst", "IATA", "Age"]) { expect(screen.getByText(h)).toBeInTheDocument(); } }); + it("no longer heads an observer column, which moved into the expansion", () => { + render(); + expect(screen.queryByText("Observer")).not.toBeInTheDocument(); + }); + it("is hidden below md", () => { const { container } = render(); expect(container.firstElementChild?.className).toContain("hidden"); @@ -23,9 +28,17 @@ describe("PacketTableHeader", () => { expect(el.style.gridTemplateColumns).toBe(GRID_TEMPLATE); }); - it("has exactly 8 cells, one per row column including the chevron spacer", () => { + it("has exactly 9 cells, one per row column including the chevron spacer", () => { const { container } = render(); - expect(container.firstElementChild?.children).toHaveLength(8); + expect(container.firstElementChild?.children).toHaveLength(9); + }); + + // Regression: the header and the rows are two independent grids. A `ch` track resolves against + // each one's own font size (header 9px vs row 11px) and an `auto`/`min-content` track against its + // own content ("HASH" vs "4AE77F09"), so either kind silently drifts the columns apart. + it("sizes every track in font-independent units so both grids resolve identically", () => { + expect(GRID_TEMPLATE).not.toMatch(/\bch\b/); + expect(GRID_TEMPLATE).not.toMatch(/auto|min-content|max-content|fit-content/); }); it("leaves the leading chevron-alignment cell unlabeled and hidden from screen readers", () => { diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 7a5e7c4e..d3597716 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen, fireEvent, within } from "@testing-library/react"; import { PacketTableRow } from "../../../src/features/packets/PacketTableRow"; -import type { PacketSummary } from "../../../src/types/api"; +import type { LatestObserver, PacketSummary, ResolvedHop } from "../../../src/types/api"; const pkt = (over: Partial = {}): PacketSummary => ({ packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT", @@ -9,6 +9,19 @@ const pkt = (over: Partial = {}): PacketSummary => ({ firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over, }); +const node = (name: string): ResolvedHop => ({ + confidence: "high", + nodes: [{ id: "n-1", name, publicKey: "aabbccdd" }], +}); + +// pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here. +const observer = ( + over: { hopCount?: number } & Partial> = {}, +): LatestObserver => { + const { hopCount = 2, ...rest } = over; + return { id: "abcdef1234", iata: "YVR", pathLength: { raw: "1e", hashSize: 1, hopCount }, ...rest }; +}; + describe("PacketTableRow", () => { it("exposes one button carrying the expansion state", () => { render( {}} />); @@ -23,27 +36,53 @@ describe("PacketTableRow", () => { expect(onToggle).toHaveBeenCalledOnce(); }); - it("renders line 2 even when there is no path data, so row height is constant", () => { - render( {}} />); - expect(screen.getAllByText("n/a")).toHaveLength(3); + it("is a single line, so the row height stays constant for the virtualizer", () => { + const { container } = render( {}} />); + expect(container.querySelectorAll("button")).toHaveLength(1); + expect(screen.queryByText("latest")).not.toBeInTheDocument(); }); - it("falls back to n/a in the observer and IATA cells when there is no observer", () => { + it("falls back to n/a in the hops, endpoint and IATA cells when there is no observer", () => { render( {}} />); - const line1 = within(screen.getByRole("button")); - expect(line1.getAllByText("n/a")).toHaveLength(2); + const row = within(screen.getByRole("button")); + expect(row.getAllByText("n/a")).toHaveLength(3); }); - it("falls back to the observer id when there is no display name", () => { - render( {}} />); - expect(screen.getByText("abcdef12")).toBeInTheDocument(); + it("no longer shows the observer, which moved into the expansion", () => { + render( {}} />); + expect(screen.queryByText("Cypress Peak")).not.toBeInTheDocument(); + expect(screen.queryByText("abcdef12")).not.toBeInTheDocument(); expect(screen.getByText("YVR")).toBeInTheDocument(); }); - it("prefers the observer display name over the id", () => { - render( {}} />); - expect(screen.getByText("Cypress Peak")).toBeInTheDocument(); - expect(screen.queryByText("abcdef12")).not.toBeInTheDocument(); + it("shows the hop count, which the REST list carries on every row", () => { + render( {}} />); + expect(screen.getByText("3")).toBeInTheDocument(); + }); + + it("renders resolved endpoints when the WS feed supplied them", () => { + render( + {}} + />, + ); + expect(screen.getByText("Laprairie")).toBeInTheDocument(); + expect(screen.getByText("YUL1")).toBeInTheDocument(); + }); + + // The REST list leaves resolvedSource/Destination nil on purpose, so scrollback rows show one n/a + // for the pair rather than "n/a → n/a". + it("collapses the endpoint cell to a single n/a when neither endpoint resolved", () => { + render( {}} />); + expect(screen.getAllByText("n/a")).toHaveLength(1); + }); + + it("still marks the missing half when only one endpoint resolved", () => { + render( {}} />); + expect(screen.getByText("Laprairie")).toBeInTheDocument(); + expect(screen.getAllByText("n/a")).toHaveLength(1); }); it("reflects the expanded state on the button and chevron", () => { From b2adc5276ff4cf4f6d2f0d169c13cdfd6cec5a5c Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 22:23:53 -0400 Subject: [PATCH 080/163] Open the analyzer from an observation and add a hash size column Drops the Open analyzer and Copy link buttons from the expansion; the analyzer popup keeps its own copy link. Table rows butt up against each other now, so the whole strip is a click target. --- src/features/packets/PacketExpansion.tsx | 18 +++-- src/features/packets/PacketTableHeader.tsx | 1 + src/features/packets/PacketTableRow.tsx | 10 +-- src/features/packets/PacketVirtualList.tsx | 5 +- src/features/packets/packet-grid.ts | 4 +- .../App.analyzerObservationCarryOver.test.tsx | 5 +- .../features/packets/PacketExpansion.test.tsx | 65 ++++++++----------- .../packets/PacketTableHeader.test.tsx | 6 +- .../features/packets/PacketTableRow.test.tsx | 16 +++-- .../packets/PacketVirtualList.test.tsx | 4 +- 10 files changed, 66 insertions(+), 68 deletions(-) diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index d609c88b..043d10d0 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -2,7 +2,6 @@ import { useCallback, useMemo } from "react"; import type { PacketSummary } from "../../types/api"; import { formatPropagation } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; -import { CopyLinkButton } from "../../components/CopyLinkButton"; import { usePacketDetail } from "./usePacketDetail"; import { ObservationTable } from "./ObservationTable"; import { buildPacketPaths } from "../map/packet-path"; @@ -35,10 +34,13 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb // than showing a blank (0-row) skeleton while it loads. const noObservations = packet.observationCount === 0; const emptyState =
No observations
; - // null drops ?analyze, so a link copied while the drawer is open still restores just the row - const copyParams = useCallback( - () => ({ tab: "Packets", hash: packet.packetHash, analyze: null }), - [packet.packetHash], + // Picking an observation is the way into the analyzer — it opens on the one you clicked. + const handleSelectObservation = useCallback( + (id: number) => { + onSelectObservation(id); + onOpenAnalyzer(); + }, + [onSelectObservation, onOpenAnalyzer], ); return ( @@ -53,9 +55,6 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb first last spread {formatPropagation(spread)} - -
@@ -91,7 +89,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb ) : data && data.observations.length === 0 ? ( emptyState ) : data ? ( - + ) : null}
diff --git a/src/features/packets/PacketTableHeader.tsx b/src/features/packets/PacketTableHeader.tsx index 5366f45d..97ae3da6 100644 --- a/src/features/packets/PacketTableHeader.tsx +++ b/src/features/packets/PacketTableHeader.tsx @@ -13,6 +13,7 @@ export function PacketTableHeader() { Route Obs Hops + Hash Size Src → Dst IATA Age diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index ff67b15b..95a430f2 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -19,7 +19,8 @@ interface PacketTableRowProps { // expansion instead, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { // ?? not ||, so a legitimate 0-hop direct packet still shows its count - const hopCount = packet.latestObserver?.pathLength?.hopCount; + const pathLength = packet.latestObserver?.pathLength; + const na = n/a; return (
{packet.scope}} ×{packet.observationCount} - - {hopCount ?? n/a} - + {pathLength?.hopCount ?? na} + {pathLength?.hashSize ?? na} - {packet.latestObserver?.iata ?? n/a} + {packet.latestObserver?.iata ?? {na}} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 2cf218bc..4cc15ad3 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -55,7 +55,7 @@ export function PacketVirtualList({ count: packets.length, getScrollElement: () => parentRef.current, // a collapsed row: one grid line on desktop, a taller card below md. Expanded rows are remeasured. - estimateSize: () => (isMobile ? 64 : 43), + estimateSize: () => (isMobile ? 64 : 37), overscan: 10, getItemKey: (index) => packets[index]?.packetHash ?? index, }); @@ -121,7 +121,8 @@ export function PacketVirtualList({ transform: `translateY(${virtualRow.start}px)`, }} > -
+ {/* cards need breathing room; table rows butt up so the whole strip is a click target */} +
{isMobile ? ( { describe("opening the analyzer from an expanded row", () => { // Regression: handleAnalyze used to reset selectedObservationId on every open, so picking an - // observation inside the expanded row and then opening the analyzer landed on observations[0] - // instead of the one clicked. + // observation inside the expanded row landed the analyzer on observations[0] instead of the one + // clicked. Selecting an observation is now what opens the analyzer, so the two happen together. it("keeps the observation selected in the expanded row", async () => { render(); fireEvent.click(await screen.findByRole("button", { name: "AA11" })); fireEvent.click(await screen.findByText("Observer Three")); - fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); const drawer = await screen.findByTestId("packet-analyzer-drawer"); expect(within(drawer).getByText("Observer Three")).toBeInTheDocument(); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index cbbf9c05..889a7df6 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; @@ -119,69 +119,58 @@ describe("PacketExpansion", () => { expect(onSelectObservation).toHaveBeenCalledWith(1); }); - it("disables both action buttons while loading", () => { + // Clicking an observation opens the analyzer, so a dedicated button would be a second way to do + // the same thing. Copy Link lives in the analyzer popup only. + it("offers neither an Open analyzer nor a Copy link button", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + expect(screen.queryByRole("button", { name: "Open analyzer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Copy row link" })).not.toBeInTheDocument(); + }); + + it("opens the analyzer on the clicked observation, selecting it first", () => { + const onOpenAnalyzer = vi.fn(); + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1), obs(2)] } }); + render(); + + fireEvent.click(screen.getByText("Observer 2")); + + expect(onSelectObservation).toHaveBeenCalledWith(2); + expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); + }); + + it("disables View path on map while loading", () => { usePacketDetail.mockReturnValue({ isLoading: true }); render(); - expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled(); expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); }); - it("disables both action buttons on error", () => { + it("disables View path on map on error", () => { usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); render(); - expect(screen.getByRole("button", { name: "Open analyzer" })).toBeDisabled(); expect(screen.getByRole("button", { name: "View path on map" })).toBeDisabled(); }); - it("enables both action buttons once the fetch resolves with a drawable path", () => { + it("enables View path on map once the fetch resolves with a drawable path", () => { usePacketDetail.mockReturnValue({ data: detailWithPath() }); render(); - expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled(); expect(screen.getByRole("button", { name: "View path on map" })).not.toBeDisabled(); }); it("disables View path on map when the loaded detail has no resolvable path", () => { usePacketDetail.mockReturnValue({ data: { packetHash: "AA11", header: header(), observations: [obs(1)] } }); render(); - expect(screen.getByRole("button", { name: "Open analyzer" })).not.toBeDisabled(); const viewPathBtn = screen.getByRole("button", { name: "View path on map" }); expect(viewPathBtn).toBeDisabled(); expect(viewPathBtn).toHaveAttribute("title", "No resolved path to map"); }); - it("calls onOpenAnalyzer and onViewPath when their buttons are clicked", () => { - const onOpenAnalyzer = vi.fn(); + it("calls onViewPath when its button is clicked", () => { const onViewPath = vi.fn(); usePacketDetail.mockReturnValue({ data: detailWithPath() }); - render(); - fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); + render(); fireEvent.click(screen.getByRole("button", { name: "View path on map" })); - expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); expect(onViewPath).toHaveBeenCalledTimes(1); }); }); - -describe("PacketExpansion copy link", () => { - const writeText = vi.fn(); - - beforeEach(() => { - Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); - writeText.mockClear(); - usePacketDetail.mockReturnValue({ data: detailWithPath() }); - }); - - afterEach(() => window.history.replaceState({}, "", "/")); - - it("copies a link to the expanded row with the analyzer stripped", () => { - window.history.replaceState({}, "", "/?tab=Packets&hash=AA11&analyze=1&iata=YVR"); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Copy row link" })); - - const copied = new URL(writeText.mock.calls[0]![0] as string); - expect(copied.searchParams.get("tab")).toBe("Packets"); - expect(copied.searchParams.get("hash")).toBe("AA11"); - expect(copied.searchParams.has("analyze")).toBe(false); // the drawer must not tag along - expect(copied.searchParams.get("iata")).toBe("YVR"); // unrelated params survive - }); -}); diff --git a/tests/features/packets/PacketTableHeader.test.tsx b/tests/features/packets/PacketTableHeader.test.tsx index 0f850439..de254c00 100644 --- a/tests/features/packets/PacketTableHeader.test.tsx +++ b/tests/features/packets/PacketTableHeader.test.tsx @@ -6,7 +6,7 @@ import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid"; describe("PacketTableHeader", () => { it("declares every column heading", () => { render(); - for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Src → Dst", "IATA", "Age"]) { + for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Hash Size", "Src → Dst", "IATA", "Age"]) { expect(screen.getByText(h)).toBeInTheDocument(); } }); @@ -28,9 +28,9 @@ describe("PacketTableHeader", () => { expect(el.style.gridTemplateColumns).toBe(GRID_TEMPLATE); }); - it("has exactly 9 cells, one per row column including the chevron spacer", () => { + it("has exactly 10 cells, one per row column including the chevron spacer", () => { const { container } = render(); - expect(container.firstElementChild?.children).toHaveLength(9); + expect(container.firstElementChild?.children).toHaveLength(10); }); // Regression: the header and the rows are two independent grids. A `ch` track resolves against diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index d3597716..27435ae2 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -16,10 +16,10 @@ const node = (name: string): ResolvedHop => ({ // pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here. const observer = ( - over: { hopCount?: number } & Partial> = {}, + over: { hopCount?: number; hashSize?: number } & Partial> = {}, ): LatestObserver => { - const { hopCount = 2, ...rest } = over; - return { id: "abcdef1234", iata: "YVR", pathLength: { raw: "1e", hashSize: 1, hopCount }, ...rest }; + const { hopCount = 2, hashSize = 1, ...rest } = over; + return { id: "abcdef1234", iata: "YVR", pathLength: { raw: "1e", hashSize, hopCount }, ...rest }; }; describe("PacketTableRow", () => { @@ -42,10 +42,16 @@ describe("PacketTableRow", () => { expect(screen.queryByText("latest")).not.toBeInTheDocument(); }); - it("falls back to n/a in the hops, endpoint and IATA cells when there is no observer", () => { + it("falls back to n/a in the hops, hash size, endpoint and IATA cells when there is no observer", () => { render( {}} />); const row = within(screen.getByRole("button")); - expect(row.getAllByText("n/a")).toHaveLength(3); + expect(row.getAllByText("n/a")).toHaveLength(4); + }); + + it("shows the hash size alongside the hop count", () => { + render( {}} />); + expect(screen.getByText("5")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); }); it("no longer shows the observer, which moved into the expansion", () => { diff --git a/tests/features/packets/PacketVirtualList.test.tsx b/tests/features/packets/PacketVirtualList.test.tsx index 3ead9726..96f80288 100644 --- a/tests/features/packets/PacketVirtualList.test.tsx +++ b/tests/features/packets/PacketVirtualList.test.tsx @@ -141,9 +141,11 @@ describe("PacketVirtualList expansion", () => { const handlers = makeHandlers(); render(); - fireEvent.click(screen.getByRole("button", { name: "Open analyzer" })); + // clicking an observation is what opens the analyzer now — there is no button for it + fireEvent.click(screen.getByText("o1")); fireEvent.click(screen.getByRole("button", { name: "View path on map" })); + expect(handlers.onSelectObservation).toHaveBeenCalledWith(1); expect(handlers.onOpenAnalyzer).toHaveBeenCalledTimes(1); expect(handlers.onViewPath).toHaveBeenCalledTimes(1); }); From 4f0ad44d25c1dbdb6f26fd8d87a8ffb973e12200 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 27 Jul 2026 23:07:24 -0400 Subject: [PATCH 081/163] Filter the region picker by IATA or airport name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing narrows both groups: airports match on code or display name, regions match on their name or on any member code — a code-only match tags the row with the code that hit, so it doesn't look like a stray result. Empty groups drop their headers so the divider never dangles. The panel body moves into its own component so the query dies with the dropdown, and focus returns to the trigger on close. --- src/components/AppShell.tsx | 175 ++++++++++++++++++++--------- tests/components/AppShell.test.tsx | 167 ++++++++++++++++++++++++++- 2 files changed, 291 insertions(+), 51 deletions(-) diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 11a02544..bcff0da4 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,4 +1,4 @@ -import { type ReactNode, useState, useEffect } from "react"; +import { type ReactNode, useState, useEffect, useMemo, useRef } from "react"; import { ErrorBoundary } from "./ErrorBoundary"; import { useQuery } from "@tanstack/react-query"; import { useRegionSelection, useRegions } from "../hooks/useRegion"; @@ -84,8 +84,43 @@ function regionSummaryLabel(selection: RegionSelection): string { // Grouped multi-select: regions (each expands to its member IATAs) on top, then individual IATAs. // Toggling keeps the dropdown open so several can be picked; "All Regions" clears the selection. function RegionSelector() { + const { selection } = useRegionSelection(); + + return ( + ( + + )} + > + {() => } + + ); +} + +// Split out from RegionSelector so the filter query lives and dies with the open panel. +function RegionSelectorPanel() { const { selection, setSelection } = useRegionSelection(); const { regions } = useRegions(); + const [query, setQuery] = useState(""); + const inputRef = useRef(null); + + // Take focus for typing, then hand it back on close — same restore rule as useFocusTrap. + useEffect(() => { + const restoreTo = document.activeElement as HTMLElement | null; + inputRef.current?.focus(); + return () => { + if (restoreTo && restoreTo !== document.body && document.contains(restoreTo)) restoreTo.focus(); + }; + }, []); const { data: iatas, isError: iatasError } = useQuery({ queryKey: ["iatas"], @@ -109,63 +144,99 @@ function RegionSelector() { }); }; + const q = query.trim().toLowerCase(); + + // A region matches on its name or on any member code. A code-only match carries those codes so the + // row can show why it surfaced — otherwise it reads as a stray result. + const shownRegions = useMemo(() => { + if (!q) return regions.map((region) => ({ region, matched: [] as string[] })); + return regions.flatMap((region) => { + if (region.name.toLowerCase().includes(q)) return [{ region, matched: [] as string[] }]; + const matched = region.iatas.filter((code) => code.toLowerCase().includes(q)); + return matched.length > 0 ? [{ region, matched }] : []; + }); + }, [regions, q]); + + // displayName is the closest thing to a city the API carries, and it's absent for IATAs the server + // auto-created from packet traffic — those stay reachable by code. + const shownIatas = useMemo(() => { + if (!iatas || !q) return iatas ?? []; + return iatas.filter( + (i) => i.iata.toLowerCase().includes(q) || (i.displayName ?? "").toLowerCase().includes(q), + ); + }, [iatas, q]); + + const showAll = !q || "all regions".includes(q); + const showIataGroup = !iatas || shownIatas.length > 0; // keep the group while loading/failed + const hasRowsAbove = showAll || shownRegions.length > 0; + return ( - ( + <> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + // Escape empties the box first; only a second press reaches Dropdown's close handler. + if (e.key === "Escape" && query) { + e.stopPropagation(); + setQuery(""); + } + }} + placeholder="Filter IATA or name…" + className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim" + /> +
+ + {showAll && ( )} - > - {() => ( - <> - - {regions.length > 0 && ( - <> -
Regions
- {regions.map((r) => { - const checked = selection.regions.includes(r.slug); - return ( - - ); - })} - - )} + {shownRegions.length > 0 && ( + <> +
Regions
+ {shownRegions.map(({ region, matched }) => { + const checked = selection.regions.includes(region.slug); + return ( + + ); + })} + + )} -
IATA
+ {showIataGroup && ( + <> +
IATA
{iatas ? ( - iatas.map((i) => { + shownIatas.map((i) => { const checked = selection.iatas.includes(i.iata); return (
-
{msg.content}
+
{msg.content}
); } diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index 87902ebc..00dade56 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -314,8 +314,9 @@ function GroupTextPayload({ payload }: PayloadProps) { )} {decrypted.content != null && (
- Message - {String(decrypted.content)} + {/* label sits above the body so every line of a multi-line message shares a left edge */} +
Message
+
{String(decrypted.content)}
)} {decrypted.sentAt != null && ( @@ -336,7 +337,7 @@ function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { {(d) => (
{d.message != null && ( -
{String(d.message)}
+
{String(d.message)}
)}
{d.timestamp != null && } @@ -380,7 +381,7 @@ function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps {String(d.tag)} )} {d.content != null && ( -
{String(d.content)}
+
{String(d.content)}
)}
)} diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index 217dd1af..ca7c44c7 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -30,9 +30,18 @@ const liveMsgB = { sentAt: 3000, } as ChannelMessage; +const multiLineMsg: ChannelMessage = { + id: 2, + packetHash: "ph-multiline", + channelHash: "ch1", + senderName: "dave", + content: "🟠\nDWD aktuell: WARNUNG vor GEWITTER\nDi 17:37 - Di 19:00", + sentAt: 4000, +}; + vi.mock("../../../src/api/client", () => ({ getChannelMessagesPage: vi.fn(() => - Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB], nextCursor: null, hasMore: false }), + Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB, multiLineMsg], nextCursor: null, hasMore: false }), ), })); @@ -70,3 +79,21 @@ describe("MessagePanel row keys", () => { errorSpy.mockRestore(); }); }); + +describe("MessagePanel multi-line messages", () => { + it("preserves linebreaks in a message body", async () => { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + + render( + + + , + ); + + // jsdom doesn't collapse whitespace the way a browser does, so the class is what pins this; + // the normalizer override stops findByText from collapsing the newlines before matching + const body = await screen.findByText(multiLineMsg.content, { normalizer: (s) => s }); + expect(body.className).toContain("whitespace-pre-wrap"); + expect(body.className).toContain("break-words"); + }); +}); diff --git a/tests/features/packets/payload-renderers.test.tsx b/tests/features/packets/payload-renderers.test.tsx index 2b695d7b..a73c01b9 100644 --- a/tests/features/packets/payload-renderers.test.tsx +++ b/tests/features/packets/payload-renderers.test.tsx @@ -172,3 +172,30 @@ describe("PayloadBreakdown — GROUP_TEXT decrypted channel message", () => { expect(screen.getByRole("tooltip").textContent).not.toBe(formatAbsolute(sentAt * 1000)); }); }); + +describe("PayloadBreakdown — multi-line message bodies", () => { + // Newlines survive the backend intact (ingest strips only NULs/invalid UTF-8), so the browser's + // default white-space:normal was collapsing them to spaces. jsdom doesn't apply that collapsing, + // so asserting on textContent alone can't catch the bug — the class is what pins it. + const body = "🟠\nDWD aktuell: WARNUNG vor GEWITTER\nDi 17:37 - Di 19:00"; + // getByText normalizes whitespace by default, which would defeat the point here + const exact = { normalizer: (s: string) => s }; + + it.each([ + ["GROUP_TEXT", { type: "GROUP_TEXT", channelHash: "ab", decrypted: { sender: "Alice", content: body } }], + ["GROUP_DATA", { type: "GROUP_DATA", channelHash: "ab", decrypted: { sender: "Alice", content: body } }], + ["TEXT_MESSAGE", { type: "TEXT_MESSAGE", cipherMac: "0011", ciphertext: "dead", decrypted: { message: body } }], + ["RESPONSE", { type: "RESPONSE", cipherMac: "0011", ciphertext: "dead", decrypted: { content: body } }], + ])("preserves linebreaks in a %s body", (_type, payload) => { + render(); + expect(screen.getByText(body, exact).className).toContain("whitespace-pre-wrap"); + }); + + it("wraps message text at word boundaries, not mid-word", () => { + const payload = { type: "GROUP_TEXT", channelHash: "ab", decrypted: { sender: "Alice", content: body } }; + render(); + // break-all is for hex/pubkeys; prose should use break-words + expect(screen.getByText(body, exact).className).toContain("break-words"); + expect(screen.getByText(body, exact).className).not.toContain("break-all"); + }); +}); From efa31433441828b3208fda09fbe3563a85d01f91 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 29 Jul 2026 23:08:52 -0400 Subject: [PATCH 084/163] Rename map live toggle to Live Map and raise its contrast --- src/features/map/PacketFlowButton.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/src/features/map/PacketFlowButton.tsx b/src/features/map/PacketFlowButton.tsx index 61862cd1..d1fe3e30 100644 --- a/src/features/map/PacketFlowButton.tsx +++ b/src/features/map/PacketFlowButton.tsx @@ -3,20 +3,21 @@ interface PacketFlowButtonProps { onToggle: () => void; } -// Floating play/stop control for the live packet-flow animation. Off by default; when live it shows -// a pulsing dot and accent styling. Bottom-center, clear of the corner map controls. +// Floating play/stop control for the live packet-flow animation. Off by default; keeps an accent +// border either way so it reads as a control, not decoration. Bottom-center, clear of the corner +// map controls. Labelled "Live Map" to distinguish it from the header's LIVE websocket badge. export function PacketFlowButton({ active, onToggle }: PacketFlowButtonProps) { return ( ); } From eb07b6ae1094a20e86cd1e605825c68bdb5a9210 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 29 Jul 2026 23:09:48 -0400 Subject: [PATCH 085/163] Bump version to 1.3.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cc34ec38..5586260e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "1.2.1", + "version": "1.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "1.2.1", + "version": "1.3.1", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index e06694ff..4cb16def 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "1.2.1", + "version": "1.3.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { From d0aa7f9c8e40e332700af957f12b7c0339e8ce99 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 29 Jul 2026 23:17:09 -0400 Subject: [PATCH 086/163] Show observation cards in the expanded row below md --- src/features/packets/PacketExpansion.tsx | 24 +++- .../packets/PacketExpansion.mobile.test.tsx | 111 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 tests/features/packets/PacketExpansion.mobile.test.tsx diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index 043d10d0..a0f82c04 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -4,6 +4,9 @@ import { formatPropagation } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { usePacketDetail } from "./usePacketDetail"; import { ObservationTable } from "./ObservationTable"; +import { ObservationCard } from "./ObservationCard"; +import { useIsMobile } from "../../hooks/useMediaQuery"; +import { PayloadType } from "../../types/enums"; import { buildPacketPaths } from "../map/packet-path"; // Roughly what fits the scroll cap; observations are unbounded server-side. @@ -24,6 +27,7 @@ interface Props { // the per-observer table, which does wait on usePacketDetail. export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) { const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash); + const isMobile = useIsMobile(); const observer = packet.latestObserver; // firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is // already in ms for formatPropagation -- no *1000 here. @@ -44,7 +48,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb ); return ( -
+
observer{" "} @@ -89,7 +93,23 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb ) : data && data.observations.length === 0 ? ( emptyState ) : data ? ( - + // The table's eight columns need ~407px, so below md its Path column lands off-screen + // behind a nested sideways scroll. Cards give the path a full-width row of its own. + isMobile ? ( +
+ {data.observations.map((o) => ( + handleSelectObservation(o.id)} + isTrace={data.header.payloadType === PayloadType.TRACE} + /> + ))} +
+ ) : ( + + ) ) : null}
diff --git a/tests/features/packets/PacketExpansion.mobile.test.tsx b/tests/features/packets/PacketExpansion.mobile.test.tsx new file mode 100644 index 00000000..1aaf3440 --- /dev/null +++ b/tests/features/packets/PacketExpansion.mobile.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketExpansion } from "../../../src/features/packets/PacketExpansion"; +import type { PacketSummary, Observation, PacketDetail } from "../../../src/types/api"; +import { PayloadType, RouteType } from "../../../src/types/enums"; + +const usePacketDetail = vi.fn(); +vi.mock("../../../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (h: string | null) => usePacketDetail(h), +})); + +// Force the below-md media query so the expansion takes its mobile path. +function setMobile(matches: boolean) { + window.matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: /hover/.test(query) ? !matches : matches, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })) as unknown as typeof window.matchMedia; +} + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 2, ...over, +}); + +const obs = (id: number, over: Partial = {}): Observation => ({ + id, observerId: `o${id}`, observerName: `Observer ${id}`, iata: "YVR", + heardAt: 1700000000 + id, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +const header = (payloadType = PayloadType.ADVERT) => ({ + raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", + payloadType, payloadTypeName: "ADVERT", payloadVersion: 1, +}); + +const detail = (observations: Observation[], payloadType = PayloadType.ADVERT): PacketDetail => + ({ packetHash: "AA11", header: header(payloadType), observations } as unknown as PacketDetail); + +const props = { + packet: pkt(), onOpenAnalyzer: () => {}, onViewPath: () => {}, + selectedObservationId: null, onSelectObservation: () => {}, +}; + +beforeEach(() => { + usePacketDetail.mockReset(); + setMobile(true); +}); +afterEach(() => vi.restoreAllMocks()); + +describe("PacketExpansion below md", () => { + // The 8-column table is ~407px wide; inside the expansion on a 390px phone that pushes Path, the + // last column, off-screen behind a nested horizontal scroll. Cards give it a full-width row. + it("renders observation cards instead of the wide table", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1), obs(2)]) }); + render(); + + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.getByText("Observer 1")).toBeInTheDocument(); + expect(screen.getByText("Observer 2")).toBeInTheDocument(); + }); + + it("shows each observation's resolved path outside of any table", () => { + usePacketDetail.mockReturnValue({ + data: detail([obs(1, { pathBytes: "414243", pathLength: { raw: "414243", hashSize: 1, hopCount: 3 } })]), + }); + render(); + + for (const hop of ["41", "42", "43"]) { + // a table cell would put it behind the nested horizontal scroll, which is the bug + expect(screen.getByText(hop).closest("table")).toBeNull(); + } + }); + + it("opens the analyzer on the tapped observation card, selecting it first", () => { + const onOpenAnalyzer = vi.fn(); + const onSelectObservation = vi.fn(); + usePacketDetail.mockReturnValue({ data: detail([obs(1), obs(2)]) }); + render(); + + fireEvent.click(screen.getByText("Observer 2")); + + expect(onSelectObservation).toHaveBeenCalledWith(2); + expect(onOpenAnalyzer).toHaveBeenCalledTimes(1); + }); + + // TRACE reuses the path field for per-hop SNR samples, so its bytes must never render as hops. + it("labels a TRACE packet's path bytes as SNR samples", () => { + usePacketDetail.mockReturnValue({ + data: detail([obs(1, { pathBytes: "3201e0", pathLength: { raw: "3201e0", hashSize: 1, hopCount: 3 } })], PayloadType.TRACE), + }); + render(); + + expect(screen.getByText("Path SNR")).toBeInTheDocument(); + expect(screen.getByText("3201E0")).toBeInTheDocument(); + }); + + it("still renders the table at md and above", () => { + setMobile(false); + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + expect(screen.getByRole("table")).toBeInTheDocument(); + }); +}); From 276ef903fb8db7a25020e11c4c2eac84547bea2b Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 2 Sep 2026 19:50:35 -0400 Subject: [PATCH 087/163] Handle API rate limiting in the client and back off WS reconnects --- src/App.tsx | 3 +- src/api/client.ts | 22 ++-- src/api/rate-limit.ts | 64 ++++++++++++ src/api/ws-manager.ts | 19 +++- src/components/AppShell.tsx | 27 +++++ src/features/packets/usePackets.ts | 4 +- src/hooks/useRateLimit.ts | 7 ++ src/lib/constants.ts | 6 ++ tests/api/client.test.ts | 73 +++++++++++++ tests/api/rate-limit.test.ts | 116 +++++++++++++++++++++ tests/api/ws-manager.test.ts | 69 ++++++++++++ tests/components/AppShell.test.tsx | 45 +++++++- tests/features/packets/usePackets.test.tsx | 18 ++++ 13 files changed, 459 insertions(+), 14 deletions(-) create mode 100644 src/api/rate-limit.ts create mode 100644 src/hooks/useRateLimit.ts create mode 100644 tests/api/rate-limit.test.ts diff --git a/src/App.tsx b/src/App.tsx index d64fd9ba..950ac213 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -29,6 +29,7 @@ import { ChannelList } from "./features/channels/ChannelList"; import { EmptyState } from "./components/EmptyState"; import { usePacketDetail } from "./features/packets/usePacketDetail"; import { WsManager } from "./api/ws-manager"; +import { shouldRetryQuery } from "./api/rate-limit"; import { WS_URL, ENABLED_TABS } from "./lib/constants"; import type { PacketDetail } from "./types/api"; @@ -44,7 +45,7 @@ const StatsOverview = lazy(() => import("./features/stats/StatsOverview").then(( const queryClient = new QueryClient({ defaultOptions: { queries: { - retry: 2, + retry: shouldRetryQuery, refetchOnWindowFocus: false, }, }, diff --git a/src/api/client.ts b/src/api/client.ts index 3e29476d..759806b6 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,5 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; +import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit"; import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; @@ -26,15 +27,25 @@ export type IataBorder = Feature; class ApiError extends Error { status: number; code: string; + retryAfterMs?: number; - constructor(status: number, code: string, message: string) { + constructor(status: number, code: string, message: string, retryAfterMs?: number) { super(message); this.name = "ApiError"; this.status = status; this.code = code; + this.retryAfterMs = retryAfterMs; } } +// Builds the error for a non-ok response; a 429 also flips the app-wide rate-limited flag. +async function errorFrom(res: Response): Promise { + const body = await res.json().catch(() => ({ error: { code: "unknown", message: res.statusText } })); + const retryAfterMs = parseRetryAfter(res.headers.get("Retry-After")); + if (res.status === 429) noteRateLimited(retryAfterMs); + return new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText, retryAfterMs); +} + async function request(path: string, params?: Record): Promise { const url = new URL(`${API_BASE}${path}`, window.location.origin); if (params) { @@ -47,10 +58,8 @@ async function request(path: string, params?: Record ({ error: { code: "unknown", message: res.statusText } })); - throw new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText); - } + if (!res.ok) throw await errorFrom(res); + noteRequestOk(); return res.json(); } @@ -89,8 +98,9 @@ export function getIatas(): Promise { export async function getIataBorder(iata: string): Promise { const url = new URL(`${API_BASE}/iatas/${iata}/border`, window.location.origin); const res = await fetch(url.toString()); + if (!res.ok) throw await errorFrom(res); + noteRequestOk(); if (res.status === 204) return null; - if (!res.ok) throw new ApiError(res.status, "unknown", res.statusText); const body = await res.json(); return (body ?? null) as IataBorder | null; } diff --git a/src/api/rate-limit.ts b/src/api/rate-limit.ts new file mode 100644 index 00000000..40e66292 --- /dev/null +++ b/src/api/rate-limit.ts @@ -0,0 +1,64 @@ +import { RATE_LIMIT_DEFAULT_MS, RATE_LIMIT_MAX_MS } from "../lib/constants"; + +// Kept free of client.ts imports so tests that mock the client with partial factories stay valid. + +// Retry-After as milliseconds. The server sends delta-seconds; the HTTP-date form is legal too. +export function parseRetryAfter(header: string | null): number | undefined { + if (!header) return undefined; + const value = header.trim(); + let ms: number; + if (/^\d+$/.test(value)) { + ms = Number(value) * 1000; + } else { + const at = Date.parse(value); + if (Number.isNaN(at)) return undefined; + ms = at - Date.now(); + } + return Math.min(Math.max(ms, 0), RATE_LIMIT_MAX_MS); +} + +// TanStack Query retry predicate: a 4xx won't improve by asking again, and retrying a 429 is exactly +// what the server just asked us not to do. Two retries for everything else, as before. +export function shouldRetryQuery(failureCount: number, error: unknown): boolean { + if (failureCount >= 2) return false; + const status = (error as { status?: unknown } | null)?.status; + if (typeof status === "number" && status >= 400 && status < 500) return false; + return true; +} + +// App-wide "the API is throttling us" flag: fed by the fetch wrapper, read by the header badge. + +let limitedUntil: number | null = null; +const listeners = new Set<() => void>(); + +function notify(): void { + for (const listener of listeners) listener(); +} + +export function noteRateLimited(retryAfterMs: number = RATE_LIMIT_DEFAULT_MS): void { + const until = Date.now() + retryAfterMs; + if (limitedUntil !== null && until <= limitedUntil) return; + limitedUntil = until; + notify(); +} + +export function noteRequestOk(): void { + if (limitedUntil === null) return; + limitedUntil = null; + notify(); +} + +export function getRateLimitedUntil(): number | null { + return limitedUntil; +} + +export function isRateLimited(): boolean { + return limitedUntil !== null && Date.now() < limitedUntil; +} + +export function subscribeRateLimit(listener: () => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index ffda8b4e..f60eaebc 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -4,8 +4,12 @@ import { WS_RECONNECT_BASE_MS, WS_RECONNECT_MAX_MS, WS_RECONNECT_JITTER, + WS_STABLE_MS, } from "../lib/constants"; +// attempt index at which the backoff first reaches WS_RECONNECT_MAX_MS +const CAP_ATTEMPT = Math.ceil(Math.log2(WS_RECONNECT_MAX_MS / WS_RECONNECT_BASE_MS)); + // handler types and status export type WsStatus = "connected" | "connecting" | "disconnected" | "error"; @@ -29,6 +33,7 @@ export class WsManager { private everConnected = false; private status: WsStatus = "disconnected"; private reconnectAttempt = 0; + private openedAt: number | null = null; private intentionalClose = false; private reconnectTimer: ReturnType | null = null; private pingTimer: ReturnType | null = null; @@ -129,6 +134,7 @@ export class WsManager { disconnect(): void { this.intentionalClose = true; this.reconnectAttempt = 0; + this.openedAt = null; this.clearTimers(); this.teardownSocket(); this.subscriptionId = null; @@ -150,7 +156,7 @@ export class WsManager { this.ws = new WebSocket(this.url); this.ws.onopen = () => { - this.reconnectAttempt = 0; + this.openedAt = Date.now(); }; this.ws.onmessage = (e: MessageEvent) => { @@ -164,14 +170,14 @@ export class WsManager { this.handleMessage(msg); }; - this.ws.onclose = () => { + this.ws.onclose = (e: CloseEvent) => { this.clearTimers(); if (this.intentionalClose) { this.setStatus("disconnected"); return; } // any unexpected close — including a server-sent 1000 — gets a reconnect - this.scheduleReconnect(); + this.scheduleReconnect(e.code); }; this.ws.onerror = () => { @@ -286,8 +292,13 @@ export class WsManager { this.scheduleReconnect(); } - private scheduleReconnect(): void { + private scheduleReconnect(closeCode?: number): void { this.setStatus("connecting"); + // only a link that actually held resets the backoff — an accept-then-close must keep escalating + if (this.openedAt !== null && Date.now() - this.openedAt >= WS_STABLE_MS) this.reconnectAttempt = 0; + this.openedAt = null; + // 1008 policy / 1013 try-again-later mean the server is shedding us: go straight to the longest wait + if (closeCode === 1008 || closeCode === 1013) this.reconnectAttempt = Math.max(this.reconnectAttempt, CAP_ATTEMPT); const base = Math.min(WS_RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, WS_RECONNECT_MAX_MS); const jitter = base * WS_RECONNECT_JITTER * (Math.random() * 2 - 1); const delay = Math.max(base + jitter, 100); diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index bcff0da4..bd70ba57 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { useRegionSelection, useRegions } from "../hooks/useRegion"; import { ALL_REGIONS, isAllRegions, type RegionSelection } from "../hooks/region-selection"; import { useWsStatus } from "../hooks/useWsStatus"; +import { useRateLimit } from "../hooks/useRateLimit"; import { useTheme } from "../hooks/useTheme"; import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; @@ -53,6 +54,31 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { ); } +// Shown while the API is throttling us; the countdown tells users the blank tables are temporary. +function RateLimitBadge() { + const until = useRateLimit(); + const [remaining, setRemaining] = useState(0); + + useEffect(() => { + if (until === null) return; + const end = until; + function update() { + setRemaining(Math.ceil((end - Date.now()) / 1000)); + } + update(); + const id = setInterval(update, 1000); + return () => clearInterval(id); + }, [until]); + + if (until === null || remaining <= 0) return null; + + return ( +
+ RATE LIMITED {remaining}s +
+ ); +} + // checkbox indicator, matching MultiSelectDropdown's style function CheckBox({ checked }: { checked: boolean }) { return ( @@ -336,6 +362,7 @@ export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShe + { setLaggedCount((prev) => prev + data.droppedCount); - queryClient.resetQueries({ queryKey: ["packets", regionKey] }); + // while the API is throttling us the refetch would only 429; the next lag notice or remount heals it + if (!isRateLimited()) queryClient.resetQueries({ queryKey: ["packets", regionKey] }); }, [queryClient, regionKey], ); diff --git a/src/hooks/useRateLimit.ts b/src/hooks/useRateLimit.ts new file mode 100644 index 00000000..8f7388bc --- /dev/null +++ b/src/hooks/useRateLimit.ts @@ -0,0 +1,7 @@ +import { useSyncExternalStore } from "react"; +import { getRateLimitedUntil, subscribeRateLimit } from "../api/rate-limit"; + +// epoch ms until which the API is throttling us, or null +export function useRateLimit(): number | null { + return useSyncExternalStore(subscribeRateLimit, getRateLimitedUntil); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index ed4cab85..97837492 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -14,6 +14,12 @@ export const WS_PING_INTERVAL_MS = 30_000; export const WS_RECONNECT_BASE_MS = 1000; export const WS_RECONNECT_MAX_MS = 30_000; export const WS_RECONNECT_JITTER = 0.25; +// a link that held this long counts as healthy, so the next drop restarts the backoff from scratch +export const WS_STABLE_MS = 10_000; + +// window to assume when a 429 arrives without Retry-After, and a ceiling on what the header can ask +export const RATE_LIMIT_DEFAULT_MS = 10_000; +export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 207a36dd..d5e3787c 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -5,6 +5,8 @@ import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; import type { ChannelMessage, ChannelSummary } from "../../src/features/channels/types"; import type { KnownRoute, TraceTagSummary, TraceDetail } from "../../src/types/api"; +import { getRateLimitedUntil, noteRequestOk } from "../../src/api/rate-limit"; +import { RATE_LIMIT_DEFAULT_MS } from "../../src/lib/constants"; // Capture the URL the client fetches and hand back a canned CursorPage. function mockFetchOnce(body: unknown): () => string { @@ -21,6 +23,8 @@ function mockFetchOnce(body: unknown): () => string { afterEach(() => { vi.unstubAllGlobals(); + vi.useRealTimers(); + noteRequestOk(); }); describe("getPackets", () => { @@ -449,6 +453,8 @@ describe("getIataBorder", () => { return { ok: status >= 200 && status < 300, status, + statusText: "status", + headers: new Headers(), json: async () => { if (status === 204) throw new Error("no body to parse"); return body; @@ -485,4 +491,71 @@ describe("getIataBorder", () => { mockStatus(200, feature); await expect(getIataBorder("YOW")).resolves.toEqual(feature); }); + + it("surfaces a 429 with the same ApiError shape as request()", async () => { + mockStatus(429, { error: { code: "rate_limited", message: "slow down" } }); + const err = await getIataBorder("YOW").catch((e: unknown) => e); + expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited" }); + }); +}); + +describe("error responses", () => { + function mockError(status: number, body: unknown, headers: Record = {}) { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status, + statusText: "status", + headers: new Headers(headers), + json: async () => body, + }) as unknown as Response), + ); + } + const limited = { error: { code: "rate_limited", message: "slow down" } }; + + it("a 429 throws an ApiError carrying status, code and retryAfterMs from the header", async () => { + mockError(429, limited, { "Retry-After": "12" }); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ name: "ApiError", status: 429, code: "rate_limited", retryAfterMs: 12_000 }); + }); + + it("a 429 without Retry-After leaves retryAfterMs undefined", async () => { + mockError(429, limited); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ status: 429, retryAfterMs: undefined }); + }); + + it("a 429 marks the app rate-limited for Retry-After", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + mockError(429, limited, { "Retry-After": "12" }); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).toBe(1_012_000); + }); + + it("a 429 without the header uses the default window", async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + mockError(429, limited); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS); + }); + + it("the next successful request clears the rate-limited state", async () => { + mockError(429, limited); + await getScopes().catch(() => {}); + expect(getRateLimitedUntil()).not.toBeNull(); + + mockFetchOnce([]); + await getScopes(); + expect(getRateLimitedUntil()).toBeNull(); + }); + + it("a plain 500 is not treated as rate limiting", async () => { + mockError(500, { error: { code: "internal", message: "boom" } }); + const err = await getScopes().catch((e: unknown) => e); + expect(err).toMatchObject({ status: 500, code: "internal" }); + expect(getRateLimitedUntil()).toBeNull(); + }); }); diff --git a/tests/api/rate-limit.test.ts b/tests/api/rate-limit.test.ts new file mode 100644 index 00000000..52160fec --- /dev/null +++ b/tests/api/rate-limit.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + parseRetryAfter, + shouldRetryQuery, + noteRateLimited, + noteRequestOk, + getRateLimitedUntil, + isRateLimited, + subscribeRateLimit, +} from "../../src/api/rate-limit"; +import { RATE_LIMIT_DEFAULT_MS, RATE_LIMIT_MAX_MS } from "../../src/lib/constants"; + +afterEach(() => { + noteRequestOk(); + vi.useRealTimers(); +}); + +describe("parseRetryAfter", () => { + it("reads delta-seconds as milliseconds", () => { + expect(parseRetryAfter("7")).toBe(7_000); + }); + + it("reads an HTTP-date relative to now", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-09-02T12:00:00Z")); + expect(parseRetryAfter("Wed, 02 Sep 2026 12:00:30 GMT")).toBe(30_000); + }); + + it("returns undefined for a missing or junk header", () => { + expect(parseRetryAfter(null)).toBeUndefined(); + expect(parseRetryAfter("soon")).toBeUndefined(); + }); + + it("clamps absurd values to the ceiling", () => { + expect(parseRetryAfter("9999")).toBe(RATE_LIMIT_MAX_MS); + }); +}); + +describe("shouldRetryQuery", () => { + it("never retries a 429", () => { + expect(shouldRetryQuery(0, { status: 429 })).toBe(false); + }); + + it("never retries a 404", () => { + expect(shouldRetryQuery(0, { status: 404 })).toBe(false); + }); + + it("retries a 5xx twice then stops", () => { + expect(shouldRetryQuery(0, { status: 500 })).toBe(true); + expect(shouldRetryQuery(1, { status: 500 })).toBe(true); + expect(shouldRetryQuery(2, { status: 500 })).toBe(false); + }); + + it("retries a network error", () => { + expect(shouldRetryQuery(0, new TypeError("Failed to fetch"))).toBe(true); + }); +}); + +describe("rate-limit store", () => { + it("starts un-limited", () => { + expect(getRateLimitedUntil()).toBeNull(); + expect(isRateLimited()).toBe(false); + }); + + it("noteRateLimited opens the window and notifies subscribers", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + const cb = vi.fn(); + const unsub = subscribeRateLimit(cb); + + noteRateLimited(5_000); + + expect(getRateLimitedUntil()).toBe(1_005_000); + expect(isRateLimited()).toBe(true); + expect(cb).toHaveBeenCalledOnce(); + unsub(); + }); + + it("uses the default window when no Retry-After was given", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000_000); + noteRateLimited(); + expect(getRateLimitedUntil()).toBe(1_000_000 + RATE_LIMIT_DEFAULT_MS); + }); + + it("is no longer limited once the window elapses", () => { + vi.useFakeTimers(); + noteRateLimited(5_000); + vi.advanceTimersByTime(5_000); + expect(isRateLimited()).toBe(false); + }); + + it("a longer window extends, a shorter one does not shrink", () => { + vi.useFakeTimers(); + vi.setSystemTime(0); + noteRateLimited(5_000); + noteRateLimited(10_000); + expect(getRateLimitedUntil()).toBe(10_000); + noteRateLimited(2_000); + expect(getRateLimitedUntil()).toBe(10_000); + }); + + it("noteRequestOk clears and notifies, and stays quiet when nothing was set", () => { + const cb = vi.fn(); + const unsub = subscribeRateLimit(cb); + + noteRequestOk(); + expect(cb).not.toHaveBeenCalled(); + + noteRateLimited(5_000); + noteRequestOk(); + expect(getRateLimitedUntil()).toBeNull(); + expect(cb).toHaveBeenCalledTimes(2); + unsub(); + }); +}); diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index 913b62c8..e6d8945d 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -404,3 +404,72 @@ describe("WsManager", () => { expect(handler).not.toHaveBeenCalled(); }); }); + +describe("WsManager reconnect backoff", () => { + // Math.random = 0.5 zeroes the jitter so delays are exact + function connectOnce() { + vi.spyOn(Math, "random").mockReturnValue(0.5); + const mgr = new WsManager("ws://test/ws"); + mgr.connect({ iatas: ["YOW"] }); + return mgr; + } + + it("keeps escalating when the server accepts then closes at once (no 1 s treadmill)", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1006); + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(2); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateClose(1006); + vi.advanceTimersByTime(1999); + expect(MockWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + + const ws3 = MockWebSocket.instances[2]!; + ws3.simulateOpen(); + ws3.simulateClose(1006); + vi.advanceTimersByTime(3999); + expect(MockWebSocket.instances).toHaveLength(3); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(4); + }); + + it("resets the backoff after a link that held for WS_STABLE_MS", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1006); + vi.advanceTimersByTime(1000); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + vi.advanceTimersByTime(10_000); + ws2.simulateClose(1006); + vi.advanceTimersByTime(1000); + expect(MockWebSocket.instances).toHaveLength(3); + }); + + it("jumps to and stays at the max backoff on a 1013 shed close", () => { + connectOnce(); + const ws1 = MockWebSocket.instances[0]!; + ws1.simulateOpen(); + ws1.simulateClose(1013); + vi.advanceTimersByTime(29_999); + expect(MockWebSocket.instances).toHaveLength(1); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(2); + + const ws2 = MockWebSocket.instances[1]!; + ws2.simulateOpen(); + ws2.simulateClose(1006); + vi.advanceTimersByTime(29_999); + expect(MockWebSocket.instances).toHaveLength(2); + vi.advanceTimersByTime(1); + expect(MockWebSocket.instances).toHaveLength(3); + }); +}); diff --git a/tests/components/AppShell.test.tsx b/tests/components/AppShell.test.tsx index 435e6829..e3c1456a 100644 --- a/tests/components/AppShell.test.tsx +++ b/tests/components/AppShell.test.tsx @@ -1,11 +1,12 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AppShell } from "../../src/components/AppShell"; import { RegionProvider } from "../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../src/hooks/region-selection"; import { getIatas, getRegions, getRegion } from "../../src/api/client"; import type { WsManager } from "../../src/api/ws-manager"; +import { noteRateLimited, noteRequestOk } from "../../src/api/rate-limit"; import pkg from "../../package.json"; vi.mock("../../src/api/client", () => ({ @@ -219,3 +220,43 @@ describe("region picker filter", () => { expect(screen.getByText("Eastern Canada")).toBeInTheDocument(); }); }); + +describe("rate limit badge", () => { + beforeEach(() => { + vi.mocked(getIatas).mockResolvedValue([]); + vi.useFakeTimers(); + }); + + afterEach(() => { + noteRequestOk(); + vi.useRealTimers(); + }); + + it("is hidden by default", () => { + renderShell(); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); + + it("shows a countdown after a 429 and hides once Retry-After elapses", () => { + renderShell(); + + act(() => noteRateLimited(5_000)); + expect(screen.getByText(/RATE LIMITED 5s/)).toHaveAttribute("role", "status"); + + act(() => vi.advanceTimersByTime(2_000)); + expect(screen.getByText(/RATE LIMITED 3s/)).toBeInTheDocument(); + + act(() => vi.advanceTimersByTime(3_000)); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); + + it("clears on the next successful request", () => { + renderShell(); + + act(() => noteRateLimited(30_000)); + expect(screen.getByText(/RATE LIMITED/)).toBeInTheDocument(); + + act(() => noteRequestOk()); + expect(screen.queryByText(/RATE LIMITED/)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index 5e7640a5..bb07f8ce 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -6,6 +6,7 @@ import { usePackets } from "../../../src/features/packets/usePackets"; import type { PacketServerFilter } from "../../../src/features/packets/types"; import type { PacketSummary } from "../../../src/types/api"; import type { WsPacketObservation } from "../../../src/types/ws"; +import { noteRateLimited, noteRequestOk } from "../../../src/api/rate-limit"; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), @@ -85,6 +86,23 @@ describe("usePackets gap healing", () => { expect(getPackets).toHaveBeenCalledTimes(1); expect(result.current.laggedCount).toBe(5); }); + + it("skips the lag reset while the API is rate-limiting us, but still counts the drop", async () => { + const { result } = renderHook(() => usePackets(), { wrapper }); + await waitFor(() => expect(getPackets).toHaveBeenCalledTimes(1)); + + seedThreePages(qc); + getPackets.mockClear(); + noteRateLimited(10_000); + act(() => { + result.current.handleLagged({ v: 1, type: "lagged", droppedCount: 5, since: 0, lastObservationId: 0 }); + }); + noteRequestOk(); + + expect(getPackets).not.toHaveBeenCalled(); + expect(qc.getQueryData<{ pages: unknown[] }>(["packets", "YOW"])?.pages).toHaveLength(3); + expect(result.current.laggedCount).toBe(5); + }); }); describe("usePackets server filter", () => { From f43f4692500d33e360215fe9474dd20aba5d71f7 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 2 Sep 2026 22:25:03 -0400 Subject: [PATCH 088/163] Color observation card edges by SNR, align card stats, join the expanded row --- src/features/packets/ObservationCard.tsx | 22 +++--- src/features/packets/PacketExpansion.tsx | 19 +++-- src/features/packets/PacketRow.tsx | 3 +- src/lib/formatters.ts | 6 ++ .../features/packets/ObservationCard.test.tsx | 72 +++++++++++++++++++ .../packets/PacketExpansion.mobile.test.tsx | 51 +++++++++++++ .../features/packets/PacketExpansion.test.tsx | 18 +++++ tests/features/packets/PacketRow.test.tsx | 32 +++++++++ 8 files changed, 206 insertions(+), 17 deletions(-) create mode 100644 tests/features/packets/ObservationCard.test.tsx create mode 100644 tests/features/packets/PacketRow.test.tsx diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx index a690933b..1fa02267 100644 --- a/src/features/packets/ObservationCard.tsx +++ b/src/features/packets/ObservationCard.tsx @@ -1,5 +1,5 @@ import type { Observation } from "../../types/api"; -import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import { formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES, SIGNAL_LEVEL_BORDER_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import { PathData } from "./PathData"; import { IataChip } from "../../components/IataChip"; @@ -14,34 +14,38 @@ export function ObservationCard({ observation: obs, selected, onClick, onViewNod className={`bg-bg-base border border-border rounded px-3 py-2.5 border-l-2 transition-colors ${ selected ? "border-l-secondary bg-secondary/5" - : "border-l-primary" + // the edge mirrors the table's SNR coloring, so the list scans as a signal column + : level + ? SIGNAL_LEVEL_BORDER_CLASSES[level] + : "border-l-primary" } ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`} onClick={onClick} >
- {obs.observerName ?? obs.observerId.slice(0, 8)} + {obs.observerName ?? obs.observerId.slice(0, 8)} {obs.iata} - +
-
-
+ {/* equal columns keep stats aligned across stacked cards and fitting any card width */} +
+
SNR {formatSnr(obs.snr)}
-
+
RSSI {obs.rssi ?? "—"}
-
+
Prop {formatPropagation(obs.propagationTimeMs)}
-
+
Hops {obs.pathLength.hopCount}
diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index a0f82c04..17374f2c 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -12,8 +12,9 @@ import { buildPacketPaths } from "../map/packet-path"; // Roughly what fits the scroll cap; observations are unbounded server-side. const SKELETON_ROW_CAP = 12; +// full-width and taller below md for a real tap target; desktop keeps its inline density const ACTION_BUTTON_CLASS = - "border border-border rounded-sm px-2 py-0.5 bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; + "border border-border rounded-sm w-full md:w-auto px-3 py-2 md:px-2 md:py-0.5 text-[11px] md:text-[10px] bg-bg-raised text-text-normal hover:bg-text-normal/3 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"; interface Props { packet: PacketSummary; @@ -47,10 +48,13 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb [onSelectObservation, onOpenAnalyzer], ); + // below md the expansion is the bottom half of the tapped card (the row's own border-b is the + // divider); at md+ it stays the flat left-accented strip. return ( -
-
- +
+
+ {/* the mobile card header right above already names the latest observer */} + observer{" "} {observer ? {observer.displayName ?? observer.id.slice(0, 8)} @@ -70,11 +74,12 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
-
+ {/* capped scroller is desktop-only; on touch it would trap the page scroll */} +
{isError ? ( -
+
Failed to load observations -
diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index 36812cc1..1c941b09 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -21,7 +21,8 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp
= { bad: "text-danger", }; +export const SIGNAL_LEVEL_BORDER_CLASSES: Record = { + good: "border-l-green", + mid: "border-l-warn", + bad: "border-l-danger", +}; + export function snrLevel(snr: number | null | undefined): SignalLevel | null { if (snr == null) return null; if (snr >= 10) return "good"; diff --git a/tests/features/packets/ObservationCard.test.tsx b/tests/features/packets/ObservationCard.test.tsx new file mode 100644 index 00000000..d9a17eae --- /dev/null +++ b/tests/features/packets/ObservationCard.test.tsx @@ -0,0 +1,72 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ObservationCard } from "../../../src/features/packets/ObservationCard"; +import type { Observation } from "../../../src/types/api"; + +const obs = (over: Partial = {}): Observation => ({ + id: 1, observerId: "o1", observerName: "Observer 1", iata: "YVR", + heardAt: 1700000000, pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, + sourceBroker: "b1", resolvedPath: [], ...over, +}); + +const tokens = (el: Element) => (el.getAttribute("class") ?? "").split(/\s+/); + +describe("ObservationCard", () => { + // jsdom can't measure layout, so these assert the class tokens that encode the behavior. + it("lets a long observer name truncate instead of overflowing the card", () => { + render(); + + const name = screen.getByText("a-very-long-observer-station-name"); + expect(tokens(name)).toEqual(expect.arrayContaining(["flex-1", "min-w-0", "truncate"])); + }); + + it("lays the four stats out as equal grid columns that can shrink", () => { + render(); + + const stats = screen.getByText("SNR").parentElement!.parentElement!; + expect(tokens(stats)).toEqual(expect.arrayContaining(["grid", "grid-cols-4"])); + for (const label of ["SNR", "RSSI", "Prop", "Hops"]) { + expect(tokens(screen.getByText(label).parentElement!)).toContain("min-w-0"); + } + }); + + // The left edge mirrors the SNR coloring the desktop table applies to its SNR column. + it.each([ + [12, "border-l-green"], + [7, "border-l-warn"], + [2, "border-l-danger"], + ])("colors the left edge by signal level (snr %s → %s)", (snr, cls) => { + const { container } = render(); + expect(tokens(container.firstElementChild!)).toContain(cls); + }); + + it("keeps a neutral edge when SNR is unknown", () => { + const { container } = render(); + expect(tokens(container.firstElementChild!)).toContain("border-l-primary"); + }); + + it("lets selection override the signal edge", () => { + const { container } = render(); + const cls = tokens(container.firstElementChild!); + expect(cls).toContain("border-l-secondary"); + expect(cls).not.toContain("border-l-green"); + }); + + // Guards the analyzer-drawer usage: content and click-through must survive the layout change. + it("still renders IATA, timestamp, path row and fires onClick", () => { + const onClick = vi.fn(); + render( + , + ); + + expect(screen.getByText("YVR")).toBeInTheDocument(); + expect(screen.getByText("8.50")).toBeInTheDocument(); + expect(screen.getByText("-108")).toBeInTheDocument(); + expect(screen.getByText("41")).toBeInTheDocument(); + fireEvent.click(screen.getByText("Observer 1")); + expect(onClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/features/packets/PacketExpansion.mobile.test.tsx b/tests/features/packets/PacketExpansion.mobile.test.tsx index 1aaf3440..b3bbdff3 100644 --- a/tests/features/packets/PacketExpansion.mobile.test.tsx +++ b/tests/features/packets/PacketExpansion.mobile.test.tsx @@ -48,6 +48,8 @@ const props = { selectedObservationId: null, onSelectObservation: () => {}, }; +const tokens = (el: Element) => (el.getAttribute("class") ?? "").split(/\s+/); + beforeEach(() => { usePacketDetail.mockReset(); setMobile(true); @@ -101,6 +103,55 @@ describe("PacketExpansion below md", () => { expect(screen.getByText("3201E0")).toBeInTheDocument(); }); + // the expansion is the unfolded bottom half of the tapped card, not a separate square slab + it("frames itself as a continuation of the packet card", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + const cls = tokens(screen.getByTestId("packet-expansion")); + expect(cls).toEqual(expect.arrayContaining(["border", "border-t-0", "rounded-b-md", "px-3.5"])); + }); + + // the card header directly above already names the latest observer, so the strip's copy is noise + it("hides the observer line that duplicates the card header", () => { + usePacketDetail.mockReturnValue({ isLoading: true }); + render(); + + const observerLine = screen.getByText("n/a").parentElement!; + expect(tokens(observerLine)).toEqual(expect.arrayContaining(["hidden", "md:inline"])); + }); + + it("sizes the summary strip and its action for touch", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + const button = screen.getByRole("button", { name: "View path on map" }); + expect(tokens(button)).toEqual( + expect.arrayContaining(["w-full", "md:w-auto", "py-2", "md:py-0.5", "text-[11px]", "md:text-[10px]"]), + ); + expect(tokens(button.parentElement!)).toEqual(expect.arrayContaining(["text-[11px]", "md:text-[10px]"])); + }); + + it("gives the retry action the same touch sizing", () => { + usePacketDetail.mockReturnValue({ isError: true, refetch: vi.fn() }); + render(); + + expect(tokens(screen.getByRole("button", { name: /retry/i }))).toEqual( + expect.arrayContaining(["py-2", "md:py-0.5"]), + ); + }); + + // a capped inner scroller inside the page scroller is a touch scroll trap; on mobile the + // observation list flows in the page instead + it("does not cap the observation list behind an inner scroller", () => { + usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); + render(); + + const cls = (screen.getByTestId("observation-scroller").getAttribute("class") ?? "").split(/\s+/); + expect(cls).not.toContain("max-h-[360px]"); + expect(cls).not.toContain("overflow-y-auto"); + }); + it("still renders the table at md and above", () => { setMobile(false); usePacketDetail.mockReturnValue({ data: detail([obs(1)]) }); diff --git a/tests/features/packets/PacketExpansion.test.tsx b/tests/features/packets/PacketExpansion.test.tsx index 889a7df6..f9361812 100644 --- a/tests/features/packets/PacketExpansion.test.tsx +++ b/tests/features/packets/PacketExpansion.test.tsx @@ -90,6 +90,24 @@ describe("PacketExpansion", () => { expect(screen.queryByTestId("observation-skeleton")).not.toBeInTheDocument(); }); + // guards against mobile card styling leaking up: at md+ the expansion stays the flat + // left-accented strip under the grid row + it("keeps the desktop strip framing at md and above", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + + const cls = (screen.getByTestId("packet-expansion").getAttribute("class") ?? "").split(/\s+/); + expect(cls).toEqual(expect.arrayContaining(["md:border-0", "md:border-l-2", "md:rounded-none", "md:pl-6"])); + }); + + it("keeps the observation scroll cap at md and above", () => { + usePacketDetail.mockReturnValue({ data: detailWithPath() }); + render(); + + const cls = (screen.getByTestId("observation-scroller").getAttribute("class") ?? "").split(/\s+/); + expect(cls).toEqual(expect.arrayContaining(["md:max-h-[360px]", "md:overflow-y-auto"])); + }); + it("renders the timing strip from the summary without waiting for the fetch", () => { usePacketDetail.mockReturnValue({ isLoading: true }); render(); diff --git a/tests/features/packets/PacketRow.test.tsx b/tests/features/packets/PacketRow.test.tsx new file mode 100644 index 00000000..d796c0fd --- /dev/null +++ b/tests/features/packets/PacketRow.test.tsx @@ -0,0 +1,32 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketRow } from "../../../src/features/packets/PacketRow"; +import type { PacketSummary } from "../../../src/types/api"; + +const pkt = (over: Partial = {}): PacketSummary => ({ + packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", + firstHeardAt: 1700000000, lastHeardAt: 1700000002, observationCount: 2, ...over, +}); + +const tokens = (el: Element) => (el.getAttribute("class") ?? "").split(/\s+/); + +describe("PacketRow", () => { + // expanded card loses its bottom radius so the expansion below reads as the same card unfolding + it("squares its bottom corners while expanded", () => { + const { container } = render( {}} />); + expect(tokens(container.firstElementChild!)).toContain("rounded-b-none"); + }); + + it("keeps rounded corners while collapsed", () => { + const { container } = render( {}} />); + expect(tokens(container.firstElementChild!)).not.toContain("rounded-b-none"); + }); + + it("toggles on click", () => { + const onToggle = vi.fn(); + render(); + fireEvent.click(screen.getByText("AA11")); + expect(onToggle).toHaveBeenCalledTimes(1); + }); +}); From d2a77942ba7188188dc5cb663259034298dfc9cd Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 2 Sep 2026 22:25:03 -0400 Subject: [PATCH 089/163] Add packet deep link investigation notes --- DEEPLINK-HANDOFF.md | 76 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 DEEPLINK-HANDOFF.md diff --git a/DEEPLINK-HANDOFF.md b/DEEPLINK-HANDOFF.md new file mode 100644 index 00000000..3de21e26 --- /dev/null +++ b/DEEPLINK-HANDOFF.md @@ -0,0 +1,76 @@ +# Packet deep link investigation — 2026-08-07 + +## Outcome: no bug in beacon-web + +A packet deep link that "doesn't pop up" was traced to a hand-built URL missing the +`analyze=1` flag. The app behaves as designed and its own generated links are correct. + +Reported URL (nothing opens): + + https://analyzer.meshmapper.net/?iata=YOW&hash=FBF4D4678D849833&tab=Packets + +Same URL with the flag (analyzer opens, fully populated): + + https://analyzer.meshmapper.net/?iata=YOW&hash=FBF4D4678D849833&tab=Packets&analyze=1 + +## Why + +`hash` alone is not "open this packet" — it's the *expanded row* selector, and the two +features are deliberately separate. + +- `App.tsx:157` — `analyze=1` gates the analyzer drawer: + `const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null;` +- `PacketList.tsx:80` — `hash` expands a row inline: `const expandedHash = searchParams.get("hash");` + +Row expansion only works if that row is in the loaded list. The reported packet was ~7h old +and the list holds the live window (50 packets, minutes), so there was no row to expand and +nothing rendered. + +Both existing comments already say this (`App.tsx:156`, `PacketList.tsx:79`). + +## Links the app generates are fine + +`PacketAnalyzerDrawer.tsx:75` sets the flag explicitly: + + + +`App.tsx:191` does the same when the analyzer opens: + + if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); } + +So "Copy Link" always produces a working link. Only hand-written URLs can miss the flag. + +## Verified + +Against prod, with the fix from beacon-server#100 deployed: + +- `GET /api/v1/packets/fbf4d4678d849833` → 200, 16 YOW observations +- Packet reachable at page 9 of the paginated YOW list +- Without `analyze=1`: list renders, no analyzer, no console errors, and one + `/api/v1/packets/FBF4D4678D849833` request fires (`usePacketDetail(expandedHash)` at + `PacketList.tsx:92`) whose result has no row to render into +- With `analyze=1`: analyzer opens — `FBF4D467 ADVERT ×16`, first/last 7h ago, all 16 + observations, raw packet, payload breakdown, `FITZZROY_ONE Repeater` + +Note the "Search by hash..." box is a *different* feature bound to `q`, filtering the loaded +list client-side with no API call. It can't find packets outside the live window. Working as +designed, but easy to mistake for a broken search. + +## Optional hardening (not a defect) + +A `hash` with no matching loaded row silently does nothing and still spends a detail fetch. +Two small options, in preference order: + +1. If `hash` is set on load and no row matches, open the analyzer instead of waiting for a row. + Makes `?hash=` alone behave the way people expect from a shared link, and reuses the + detail request already in flight. +2. Leave behaviour alone but surface the miss — a one-line "packet not in the live window" + affordance with a button to open the analyzer. + +Neither is required for correctness. Option 1 removes the whole class of report. + +## Related + +beacon-server#100 fixed a genuine backend bug found while chasing this: IATA-filtered packet +lists stopped paginating early, stranding older history. Real bug, separate cause — it was +never what broke this deep link. From 12ddc8a921a350b66294f91132a9f5bbea62b33d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 9 Sep 2026 23:21:39 -0400 Subject: [PATCH 090/163] Observer tab: airtime as percent, add heard-activity charts Airtime telemetry is on-air seconds, so chart it as a percent of elapsed time. Add channel busy, packets heard, SNR heard and payload type charts from the new per-observer activity endpoint; hidden until the server has it. --- src/api/client.ts | 10 ++ src/features/stats/MeshTab.tsx | 10 +- src/features/stats/ObserverTab.tsx | 96 ++++++++++--- src/features/stats/chartOptions.ts | 113 ++++++++++++--- src/features/stats/transforms.ts | 73 +++++++++- src/features/stats/types.ts | 31 +++- src/features/stats/useTelemetry.ts | 31 +++- src/lib/formatters.ts | 23 ++- tests/api/client.test.ts | 35 ++++- tests/features/stats/ObserverTab.test.tsx | 160 +++++++++++++++++++++ tests/features/stats/chart-options.test.ts | 96 +++++++++++-- tests/features/stats/transforms.test.ts | 142 ++++++++++++++++++ tests/features/stats/useLiveStats.test.tsx | 29 +++- tests/features/stats/useTelemetry.test.ts | 22 +++ tests/lib/formatters.test.ts | 18 +++ 15 files changed, 819 insertions(+), 70 deletions(-) create mode 100644 tests/features/stats/ObserverTab.test.tsx create mode 100644 tests/features/stats/transforms.test.ts create mode 100644 tests/features/stats/useTelemetry.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index 759806b6..b061f855 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -15,6 +15,7 @@ import type { RadioPreset, ScopeStats, ObserverTelemetry, + ObserverActivity, NodeTypeCount, ClockDriftEntry, } from "../features/stats/types"; @@ -348,4 +349,13 @@ export function getObserverTelemetry( return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); } +export function getObserverActivity(observerId: string, range: string, interval: string): Promise { + return request(`/observers/${observerId}/activity`, { range, interval }); +} + +// Lets a caller hide a feature the server doesn't have rather than show it as failed. +export function isNotFound(err: unknown): boolean { + return err instanceof ApiError && err.status === 404; +} + export { ApiError }; diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index f43a0206..c4e5e365 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -5,7 +5,7 @@ import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNode import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; -import { aggregatePresets, formatPreset } from "./transforms"; +import { aggregatePresets, formatPreset, payloadBarItems } from "./transforms"; import type { WsManager } from "../../api/ws-manager"; import type { ObservationPoint, StatsRange } from "./types"; @@ -58,13 +58,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { ); const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); - const payloadItems = useMemo( - () => - (payload.data ?? []) - .map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })) - .sort((a, b) => b.value - a.value), - [payload.data], - ); + const payloadItems = useMemo(() => payloadBarItems(payload.data ?? []), [payload.data]); const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]); const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 71eab28a..639fb067 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -2,21 +2,31 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { Badge } from "../../components/Badge"; import { EmptyState } from "../../components/EmptyState"; -import { formatBattery, formatCount, formatUptime } from "../../lib/formatters"; +import { formatBattery, formatCount, formatRadioParts, formatUptime } from "../../lib/formatters"; import { getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { useChartColors } from "./chartTheme"; import { useTopObservers } from "./useStats"; -import { useObserver, useObserverTelemetry } from "./useTelemetry"; +import { activityParamsFor, useObserver, useObserverActivity, useObserverTelemetry } from "./useTelemetry"; import { useTick } from "../../hooks/useTick"; import { deriveObserverStatus } from "../observers/observer-status"; -import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption } from "./chartOptions"; +import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption, typeBarOption } from "./chartOptions"; import { Card, ChartCard } from "./cards"; -import { hasTelemetry } from "./transforms"; +import { fillActivity, hasTelemetry, intervalToMs, latestAirtimePct, payloadBarItems } from "./transforms"; import { useLiveObserver } from "./useLiveStats"; +import { isNotFound } from "../../api/client"; import type { WsManager } from "../../api/ws-manager"; import type { Observer } from "../observers/types"; -import type { StatsRange } from "./types"; +import { RANGE_MS, type StatsRange } from "./types"; + +// how the "Heard per …" title reads for each bucket the activity endpoint returns +const PER_BUCKET: Record = { "5m": "5 min", "15m": "15 min", "1h": "hour", "6h": "6 h", "24h": "day" }; + +type AirtimePct = { rx: number | null; tx: number | null }; + +function airtimeLabel(a: AirtimePct): string { + return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); +} function ObserverList({ range, @@ -111,15 +121,10 @@ function ObserverList({ ); } -function ObserverHeader({ observer }: { observer: Observer }) { +function ObserverHeader({ observer, airtime }: { observer: Observer; airtime: AirtimePct }) { useTick(); // keep the recency-derived status badge fresh const status = deriveObserverStatus(observer); - const radio = [ - observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, - observer.radioSf && `SF${observer.radioSf}`, - observer.radioBwKhz && `${observer.radioBwKhz} kHz`, - observer.radioCr && `CR 4/${observer.radioCr}`, - ].filter(Boolean) as string[]; + const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); return ( - {radio.length > 0 && } + {(airtime.rx != null || airtime.tx != null) && } + {radio && }
); @@ -166,6 +172,7 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const topObservers = useTopObservers(range, 15); const observer = useObserver(selectedObserverId); const telemetry = useObserverTelemetry(selectedObserverId, range); + const activity = useObserverActivity(selectedObserverId, range); // default to the busiest observer once the list loads and nothing is selected useEffect(() => { @@ -176,8 +183,14 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points - const bucketed = telemetry.data != null && telemetry.data.interval !== "1h"; - const airtime = useMemo(() => airtimeOption(points, colors, bucketed), [points, colors, bucketed]); + const bucketMs = telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; + const bucketed = bucketMs != null; + const airtime = useMemo(() => airtimeOption(points, colors, bucketMs), [points, colors, bucketMs]); + // bucketed values span less than their bucket, so only the hourly series gives an honest header number + const latestAirtime = useMemo( + () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), + [points, bucketMs], + ); const battery = useMemo(() => batteryOption(points, colors), [points, colors]); const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); const queue = useMemo(() => queueOption(points, colors), [points, colors]); @@ -191,6 +204,34 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) => ready && !points.some((p) => accessors.some((a) => a(p) != null)); + // a 404 means this server has no activity endpoint yet: hide the heard group rather than show failed cards + const heardUnavailable = activity.isError && isNotFound(activity.error); + // a placeholder is the previous selection's data, so treat it as loading rather than read anything from it + const heardLoading = activity.isLoading || activity.isPlaceholderData; + const heardData = activity.isPlaceholderData ? undefined : activity.data; + const intervalMs = heardData ? intervalToMs(heardData.interval) : null; + // the window ends at the last fetch so the right edge follows now on every poll + const heardWindow = useMemo(() => { + const end = activity.dataUpdatedAt; + const span = (heardData && intervalToMs(heardData.range)) ?? RANGE_MS[range]; + return { start: end - span, end }; + }, [heardData, activity.dataUpdatedAt, range]); + const heard = useMemo( + () => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow) : []), + [heardData, intervalMs, heardWindow], + ); + const busy = useMemo(() => busyOption(heard, colors, intervalMs, heardWindow), [heard, colors, intervalMs, heardWindow]); + const heardCount = useMemo(() => heardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); + const snr = useMemo(() => snrHeardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); + const payloadItems = useMemo(() => payloadBarItems(heardData?.payloadTypes ?? []), [heardData]); + const payload = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); + const nothingHeard = heardData != null && heardData.points.length === 0; + const costed = heardData?.points.some((p) => p.airtimeMs != null) ?? false; + const heardSnr = heard.some((p) => p.snrAvg != null); + const radioLabel = formatRadioParts(heardData?.radio ?? {}); + const interval = heardData?.interval ?? activityParamsFor(range).interval; + const perBucket = PER_BUCKET[interval] ?? interval; + return (
@@ -204,7 +245,7 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan ) : ( <> - {observer.data && } + {observer.data && } {noTelemetry ? ( @@ -227,6 +268,29 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan
)} + {!heardUnavailable && + (nothingHeard ? ( + + + + ) : ( + <> + Channel busy · {range}} + right={radioLabel && {radioLabel}} + height={180} + option={busy} + isLoading={heardLoading} + isError={activity.isError} + isEmpty={heardData != null && !costed} + /> +
+ + +
+ + + ))} )}
diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 9b0e8406..ca5d7902 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -1,7 +1,8 @@ import type { EChartsOption } from "./echarts-setup"; import { type ChartColors, tooltipStyle, withAlpha } from "./chartTheme"; import { formatCount } from "../../lib/formatters"; -import type { TelemetryPoint } from "./types"; +import { airtimePctSeries, busyPct } from "./transforms"; +import type { ActivityPoint, TelemetryPoint } from "./types"; const MONO = "JetBrains Mono, monospace"; @@ -307,33 +308,24 @@ export function typeBarOption( // ---- Observer telemetry ---- // `t` arrives in epoch ms. -// 1h points are cumulative counters, so chart per-report deltas (clamped at 0 for resets); -// bucketed points already arrive as per-bucket deltas, so chart those as-is. -function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxPct") { - const out: [number, number | null][] = []; - for (let i = 1; i < points.length; i++) { - const prev = points[i - 1]![key]; - const cur = points[i]![key]; - const d = prev != null && cur != null ? Math.max(0, cur - prev) : null; - out.push([points[i]!.t, d]); - } - return out; +function percentAxis(c: ChartColors) { + return valueAxis(c, { axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value}%" } }); } -export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketed: boolean): EChartsOption { - const series = (key: "airtimeRxPct" | "airtimeTxPct") => - bucketed ? points.map((p) => [p.t, p[key]]) : deltaSeries(points, key); +const pctLabel = (v: unknown) => (typeof v === "number" ? `${v}%` : "—"); + +export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs: number | null): EChartsOption { return { animation: false, backgroundColor: "transparent", - grid: { left: 44, right: 14, top: 24, bottom: 22 }, + grid: { left: 48, right: 14, top: 24, bottom: 22 }, legend: { data: ["RX", "TX"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, - tooltip: { trigger: "axis", ...tooltipStyle(c) }, + tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: pctLabel }, xAxis: timeAxis(c), - yAxis: valueAxis(c), + yAxis: percentAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: series("airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxPct", bucketMs), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxPct", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, ], }; } @@ -391,3 +383,84 @@ export const queueOption = (p: TelemetryPoint[], c: ChartColors) => // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); + +// ---- Observer activity (what it heard) ---- + +export interface TimeWindow { + start: number; // epoch ms + end: number; +} + +// Pin the axis to the selected range so a quiet observer shows empty space up to now. +function windowAxis(c: ChartColors, w: TimeWindow) { + return { ...timeAxis(c), min: w.start, max: w.end }; +} + +// The bucket still in progress is measured against the time elapsed so far, not the full width. +function busySpanMs(t: number, intervalMs: number, w: TimeWindow): number { + return t < w.end && t + intervalMs > w.end ? w.end - t : intervalMs; +} + +export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: number | null, w: TimeWindow): EChartsOption { + const pct = (p: ActivityPoint) => (intervalMs == null ? null : busyPct(p.airtimeMs, busySpanMs(p.t, intervalMs, w))); + return { + animation: false, + backgroundColor: "transparent", + grid: { left: 48, right: 14, top: 14, bottom: 22 }, + tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: pctLabel }, + xAxis: windowAxis(c, w), + yAxis: percentAxis(c), + series: [ + { + name: "Busy", + type: "line", + symbol: "none", + connectNulls: false, + data: points.map((p) => [p.t, pct(p)]), + lineStyle: { width: 1.5, color: c.green }, + areaStyle: { color: withAlpha(c.green, 0.28) }, + itemStyle: { color: c.green }, + }, + ], + }; +} + +export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { + return { + animation: false, + backgroundColor: "transparent", + grid: { left: 48, right: 14, top: 14, bottom: 22 }, + tooltip: { trigger: "axis", ...tooltipStyle(c) }, + xAxis: windowAxis(c, w), + yAxis: valueAxis(c, { minInterval: 1 }), + series: [ + { + name: "Heard", + type: "line", + symbol: "none", + data: points.map((p) => [p.t, p.observations]), + lineStyle: { width: 1.5, color: c.primary }, + areaStyle: { color: withAlpha(c.primary, 0.28) }, + itemStyle: { color: c.primary }, + }, + ], + }; +} + +const dbLabel = (v: unknown) => (typeof v === "number" ? `${v} dB` : "—"); + +export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { + return { + animation: false, + backgroundColor: "transparent", + grid: { left: 54, right: 14, top: 24, bottom: 22 }, + legend: { data: ["Avg", "Min"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, + tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: dbLabel }, + xAxis: windowAxis(c, w), + yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), + series: [ + { name: "Avg", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, + { name: "Min", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, + ], + }; +} diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index a6d32560..4f76bc25 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -1,4 +1,4 @@ -import type { RadioPreset, TelemetryPoint } from "./types"; +import type { ActivityPoint, PayloadBreakdownItem, RadioPreset, TelemetryPoint } from "./types"; // Collapse presets to one row each (keeping the node/observer split), dropping junk "0,0,0" configs. export function aggregatePresets(rows: RadioPreset[]): { preset: string; nodes: number; observers: number }[] { @@ -45,3 +45,74 @@ export function hasTelemetry(points: TelemetryPoint[]): boolean { live(p.receiveErrors), ); } + +// "15m" / "6h" → ms; null for anything the server wouldn't send. +export function intervalToMs(interval: string): number | null { + const m = /^(\d+)([mh])$/.exec(interval); + if (!m) return null; + const n = Number(m[1]); + return m[2] === "m" ? n * 60_000 : n * 3_600_000; +} + +// share of `windowSeconds` spent on air, rounded so tooltips don't show float noise +function pct(seconds: number, windowSeconds: number): number { + return Math.round(((seconds * 100) / windowSeconds) * 1000) / 1000; +} + +type AirtimeKey = "airtimeRxPct" | "airtimeTxPct"; + +// The values are on-air seconds despite the field name: cumulative on raw 1h points, per-bucket on +// bucketed ones. Chart the increase as a percent of the elapsed time, clamped at 0 on counter resets. +export function airtimePctSeries(points: TelemetryPoint[], key: AirtimeKey, bucketMs: number | null): [number, number | null][] { + if (bucketMs != null) { + return points.map((p) => { + const v = p[key]; + return [p.t, v == null ? null : pct(v, bucketMs / 1000)]; + }); + } + const out: [number, number | null][] = []; + for (let i = 1; i < points.length; i++) { + const prev = points[i - 1]!; + const cur = points[i]!; + const a = prev[key]; + const b = cur[key]; + const gapMs = cur.t - prev.t; + out.push([cur.t, a != null && b != null && gapMs > 0 ? pct(Math.max(0, b - a), gapMs / 1000) : null]); + } + return out; +} + +// Most recent RX / TX percent, for the header stat. +export function latestAirtimePct(points: TelemetryPoint[], bucketMs: number | null): { rx: number | null; tx: number | null } { + const last = (key: AirtimeKey) => { + const s = airtimePctSeries(points, key, bucketMs); + return s.length ? s[s.length - 1]![1] : null; + }; + return { rx: last("airtimeRxPct"), tx: last("airtimeTxPct") }; +} + +// The server skips empty buckets; fill them so a quiet stretch draws as zero instead of a skipped line. +// Starts at the first complete bucket (the server rounds its window start up the same way) and lets +// one bucket past the window end through, so a client clock behind the server can't hide fresh data. +export function fillActivity(points: ActivityPoint[], intervalMs: number, window: { start: number; end: number }): ActivityPoint[] { + const snap = (t: number) => Math.floor(t / intervalMs) * intervalMs; + const first = Math.ceil(window.start / intervalMs) * intervalMs; + const current = snap(window.end); + const last = points.some((p) => snap(p.t) === current + intervalMs) ? current + intervalMs : current; + const byBucket = new Map(points.map((p) => [snap(p.t), p])); + const out: ActivityPoint[] = []; + for (let t = first; t <= last; t += intervalMs) { + out.push(byBucket.get(t) ?? { t, observations: 0, airtimeMs: 0, snrAvg: null, snrMin: null, rssiAvg: null }); + } + return out; +} + +// Payload breakdown rows as the bar chart wants them: busiest first, lowercase names. +export function payloadBarItems(items: PayloadBreakdownItem[]): { name: string; value: number }[] { + return [...items].sort((a, b) => b.count - a.count).map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })); +} + +// Percent of a bucket spent receiving; null when the server couldn't cost the bucket. +export function busyPct(airtimeMs: number | null, intervalMs: number): number | null { + return airtimeMs == null ? null : pct(airtimeMs / 1000, intervalMs / 1000); +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index f3c72f7f..3d4138e6 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -1,4 +1,5 @@ -// Response shapes for the /stats/* endpoints and observer telemetry. Verified against beacon-server. +// Response shapes for the /stats/* endpoints and observer telemetry. Verified against beacon-server, +// except the activity block, which follows the endpoint handoff until the server ships it. import type { NodeIATA } from "../nodes/types"; @@ -98,7 +99,7 @@ export interface ScopeStats { export interface TelemetryPoint { t: number; // epoch ms batteryMv: number | null; - airtimeTxPct: number | null; + airtimeTxPct: number | null; // on-air seconds despite the name: cumulative on 1h points, per-bucket otherwise airtimeRxPct: number | null; noiseFloorDb: number | null; uptimeSeconds: number | null; @@ -112,6 +113,32 @@ export interface ObserverTelemetry { points: TelemetryPoint[]; } +// GET /observers/{id}/activity: what the observer heard per `interval`. The tab hides these charts on a 404. +export interface ActivityPoint { + t: number; // epoch ms, bucket start + observations: number; + airtimeMs: number | null; // summed LoRa time-on-air; null when no row in the bucket could be costed + snrAvg: number | null; + snrMin: number | null; + rssiAvg: number | null; +} + +export interface ActivityRadio { + freqMhz: number | null; + sf: number | null; + bwKhz: number | null; + cr: number | null; + preambleSymbols: number | null; +} + +export interface ObserverActivity { + range: string; + interval: string; + radio: ActivityRadio | null; + payloadTypes: PayloadBreakdownItem[]; + points: ActivityPoint[]; +} + // Sub-tab + time-range identifiers shared across the Stats page. export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index f2ec34c5..217f4c33 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,5 +1,5 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { getObserver, getObserverTelemetry } from "../../api/client"; +import { getObserver, getObserverTelemetry, getObserverActivity, isNotFound } from "../../api/client"; import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. @@ -27,6 +27,35 @@ export function useObserver(observerId: string | null) { }); } +// Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points. +const ACTIVITY_INTERVAL: Record = { + "24h": "15m", + "7d": "1h", + "30d": "6h", +}; + +export function activityParamsFor(range: StatsRange): { range: string; interval: string } { + return { range: RANGE_PARAM[range], interval: ACTIVITY_INTERVAL[range] }; +} + +// Heard activity moves with packets, so poll; stop once a 404 says this server has no endpoint. +export function activityRefetchInterval(error: unknown): number | false { + return isNotFound(error) ? false : 60_000; +} + +export function useObserverActivity(observerId: string | null, range: StatsRange) { + const params = activityParamsFor(range); + return useQuery({ + queryKey: ["observer-activity", observerId, range], + queryFn: () => getObserverActivity(observerId!, params.range, params.interval), + enabled: !!observerId, + staleTime: 30_000, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + refetchInterval: (query) => activityRefetchInterval(query.state.error), + }); +} + export function useObserverTelemetry(observerId: string | null, range: StatsRange) { const interval = INTERVAL_PARAM[range]; return useQuery({ diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index e7b8cee2..2dc030b6 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -110,14 +110,25 @@ export function timeAgoMs(epochMs: number): string { return `${Math.floor(hours / 24)}d`; } -// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915,250,11"). -// Formats freq/SF/bandwidth like the observer panel ("915 MHz · SF11 · 250 kHz"); the compact -// string carries no coding rate, so there's no "CR 4/x" segment. +// One radio config format for every panel ("915 MHz · SF11 · 250 kHz · CR 4/5"); unknown or zero parts drop out. +export function formatRadioParts(r: { freqMhz?: number | null; sf?: number | null; bwKhz?: number | null; cr?: number | null }): string | null { + const known = (v: number | null | undefined): v is number => v != null && v > 0; + const parts = [ + known(r.freqMhz) && `${r.freqMhz} MHz`, + known(r.sf) && `SF${r.sf}`, + known(r.bwKhz) && `${r.bwKhz} kHz`, + known(r.cr) && `CR 4/${r.cr}`, + ].filter(Boolean) as string[]; + return parts.length > 0 ? parts.join(" · ") : null; +} + +// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915,250,11"); the +// compact string carries no coding rate, so there's no "CR 4/x" segment. export function formatRadio(radio: string | null | undefined): string | null { if (!radio) return null; const [freq, bw, sf] = radio.split(","); if (!freq || !bw || !sf) return radio; // unexpected shape — show it raw rather than hide it - const f = Number(freq), b = Number(bw); - if (Number.isNaN(f) || Number.isNaN(b)) return radio; // non-numeric freq/bw — show raw, not "NaN MHz" - return `${f} MHz · SF${sf} · ${b} kHz`; + const f = Number(freq), b = Number(bw), s = Number(sf); + if (Number.isNaN(f) || Number.isNaN(b) || Number.isNaN(s)) return radio; // non-numeric — show raw, not "NaN MHz" + return formatRadioParts({ freqMhz: f, sf: s, bwKhz: b }); } diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index d5e3787c..4709eba2 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client"; import type { Feature, Polygon } from "geojson"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; @@ -559,3 +559,36 @@ describe("error responses", () => { expect(getRateLimitedUntil()).toBeNull(); }); }); + +describe("getObserverActivity", () => { + it("requests the observer's heard-activity series for a range and bucket interval", async () => { + const getUrl = mockFetchOnce({ range: "24h", interval: "15m", radio: null, payloadTypes: [], points: [] }); + + await getObserverActivity("obs-1", "24h", "15m"); + + const url = new URL(getUrl()); + expect(url.pathname).toContain("/observers/obs-1/activity"); + expect(url.searchParams.get("range")).toBe("24h"); + expect(url.searchParams.get("interval")).toBe("15m"); + }); +}); + +describe("isNotFound", () => { + it("is true only for a 404 from the API", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: false, + status: 404, + statusText: "Not Found", + headers: new Headers(), + json: async () => ({ error: { code: "not_found", message: "no such observer" } }), + })), + ); + + const err = await getObserverActivity("obs-1", "24h", "15m").catch((e: unknown) => e); + + expect(isNotFound(err)).toBe(true); + expect(isNotFound(new Error("network down"))).toBe(false); + }); +}); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx new file mode 100644 index 00000000..1e60110f --- /dev/null +++ b/tests/features/stats/ObserverTab.test.tsx @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { ObserverTab } from "../../../src/features/stats/ObserverTab"; +import { ApiError } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../../../src/features/stats/types"; +import type { WsManager } from "../../../src/api/ws-manager"; + +vi.mock("../../../src/hooks/useRegion", () => ({ + useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), +})); + +vi.mock("../../../src/features/stats/useStats", () => ({ + useTopObservers: () => ({ data: [], isLoading: false }), +})); + +vi.mock("../../../src/features/stats/useLiveStats", () => ({ + useLiveObserver: () => {}, +})); + +// ECharts needs a real canvas; the tab's behaviour is in which cards it renders, not the pixels +vi.mock("../../../src/features/stats/EChart", () => ({ + EChart: () =>
, +})); + +const telemetryResult = { data: undefined as ObserverTelemetry | undefined, isLoading: false, isError: false }; +const activityResult = { + data: undefined as ObserverActivity | undefined, + isLoading: false, + isPlaceholderData: false, + isError: false, + error: null as unknown, + dataUpdatedAt: 0, +}; + +vi.mock("../../../src/features/stats/useTelemetry", async (importOriginal) => ({ + ...(await importOriginal()), + useObserver: () => ({ data: observer }), + useObserverTelemetry: () => telemetryResult, + useObserverActivity: () => activityResult, +})); + +const observer: Observer = { + id: "obs-1", + displayName: "Rooftop", + iata: "YOW", + status: "online", + publicKey: "aa", + firstSeen: 0, + lastSeen: Date.now(), + observationCount: 12, + brokers: [], + radioFreqMhz: 910.525, + radioSf: 7, + radioBwKhz: 62.5, + radioCr: 5, +}; + +const H = 3_600_000; + +const point = (t: number, p: Partial): TelemetryPoint => ({ + t, + batteryMv: null, + airtimeTxPct: null, + airtimeRxPct: null, + noiseFloorDb: null, + uptimeSeconds: null, + queueLength: null, + receiveErrors: null, + ...p, +}); + +const telemetry: ObserverTelemetry = { + range: "24h", + interval: "1h", + points: [point(0, { airtimeRxPct: 100, airtimeTxPct: 10, batteryMv: 4100 }), point(H, { airtimeRxPct: 154, airtimeTxPct: 46, batteryMv: 4100 })], +}; + +const activity: ObserverActivity = { + range: "24h", + interval: "15m", + radio: { freqMhz: 910.525, sf: 7, bwKhz: 62.5, cr: 5, preambleSymbols: 32 }, + payloadTypes: [{ payloadType: 4, payloadTypeName: "ADVERT", count: 9 }], + points: [{ t: Date.now() - 900_000, observations: 9, airtimeMs: 2200, snrAvg: 6.1, snrMin: -1, rssiAvg: -95 }], +}; + +function renderTab() { + const qc = new QueryClient(); + const wrapper = ({ children }: { children: ReactNode }) => {children}; + return render( {}} wsManager={{} as WsManager} />, { wrapper }); +} + +beforeEach(() => { + telemetryResult.data = telemetry; + telemetryResult.isLoading = false; + telemetryResult.isError = false; + activityResult.data = activity; + activityResult.isLoading = false; + activityResult.isPlaceholderData = false; + activityResult.isError = false; + activityResult.error = null; + activityResult.dataUpdatedAt = Date.now(); +}); + +describe("ObserverTab", () => { + it("shows the latest reported RX and TX airtime as percent in the header", () => { + renderTab(); + // +54 s RX and +36 s TX over the hour between the two reports + expect(screen.getByText(/RX 1\.5%/)).toBeInTheDocument(); + expect(screen.getByText(/TX 1%/)).toBeInTheDocument(); + }); + + it("renders the heard charts when the server has activity for the observer", () => { + renderTab(); + expect(screen.getByText(/channel busy/i)).toBeInTheDocument(); + expect(screen.getByText(/heard per 15 min/i)).toBeInTheDocument(); + expect(screen.getByText(/snr heard/i)).toBeInTheDocument(); + expect(screen.getByText(/payload types heard/i)).toBeInTheDocument(); + }); + + it("names the radio settings the busy percent assumes, in the same form as the header", () => { + renderTab(); + expect(screen.getAllByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toHaveLength(2); + }); + + it("hides the heard charts entirely when the server does not have the endpoint", () => { + activityResult.data = undefined; + activityResult.isError = true; + activityResult.error = new ApiError(404, "not_found", "no route"); + renderTab(); + expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/heard/i)).not.toBeInTheDocument(); + }); + + it("leaves the header airtime stat out when the observer reports no telemetry", () => { + const zero = point(0, { airtimeRxPct: 0, airtimeTxPct: 0 }); + telemetryResult.data = { ...telemetry, points: [zero, { ...zero, t: H }] }; + renderTab(); + expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument(); + }); + + it("shows the heard cards as loading while the previous selection's data is only a placeholder", () => { + activityResult.data = { ...activity, points: [] }; + activityResult.isPlaceholderData = true; + activityResult.dataUpdatedAt = 0; + renderTab(); + expect(screen.getAllByText("Loading…").length).toBeGreaterThan(0); + expect(screen.queryByText(/no packets heard/i)).not.toBeInTheDocument(); + expect(screen.queryByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toBeInTheDocument(); // header only + }); + + it("shows one empty card instead of flat charts when nothing was heard", () => { + activityResult.data = { ...activity, payloadTypes: [], points: [] }; + renderTab(); + expect(screen.getByText(/no packets heard/i)).toBeInTheDocument(); + expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index 6a4784dc..3688c134 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any -- poking into loose ECharts option shapes */ import { describe, it, expect } from "vitest"; -import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption } from "../../../src/features/stats/chartOptions"; +import { typeBarOption, leaderboardOption, donutOption, presetBarsOption, airtimeOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption } from "../../../src/features/stats/chartOptions"; import type { ChartColors } from "../../../src/features/stats/chartTheme"; -import type { TelemetryPoint } from "../../../src/features/stats/types"; +import type { ActivityPoint, TelemetryPoint } from "../../../src/features/stats/types"; const colors: ChartColors = { primary: "#3b82f6", @@ -142,23 +142,93 @@ const point = (t: number, p: Partial): TelemetryPoint => ({ ...p, }); +const H = 3_600_000; + describe("airtimeOption", () => { const points = [ - point(1000, { airtimeRxPct: 10, airtimeTxPct: 4 }), - point(2000, { airtimeRxPct: 12, airtimeTxPct: 4 }), - point(3000, { airtimeRxPct: 11, airtimeTxPct: 7 }), + point(0, { airtimeRxPct: 10, airtimeTxPct: 4 }), + point(H, { airtimeRxPct: 46, airtimeTxPct: 4 }), // +36 s over 1 h → 1% + point(3 * H, { airtimeRxPct: 40, airtimeTxPct: 76 }), // RX dips → clamp at 0; TX +72 s over 2 h → 1% ]; - it("charts raw counters as clamped per-report deltas", () => { - const opt = airtimeOption(points, colors, false) as Record; - expect(opt.series[0].data).toEqual([[2000, 2], [3000, 0]]); // RX dips → clamp at 0 - expect(opt.series[1].data).toEqual([[2000, 0], [3000, 3]]); // TX + it("charts raw counters as percent of the wall-clock gap between reports", () => { + const opt = airtimeOption(points, colors, null) as Record; + expect(opt.series[0].name).toBe("RX"); + expect(opt.series[0].data).toEqual([[H, 1], [3 * H, 0]]); + expect(opt.series[1].data).toEqual([[H, 0], [3 * H, 1]]); }); - it("charts bucketed points as-is", () => { - const opt = airtimeOption(points, colors, true) as Record; - expect(opt.series[0].data).toEqual([[1000, 10], [2000, 12], [3000, 11]]); - expect(opt.series[1].data).toEqual([[1000, 4], [2000, 4], [3000, 7]]); + it("charts bucketed deltas as percent of the bucket width", () => { + const bucketed = [point(0, { airtimeRxPct: 216, airtimeTxPct: 0 }), point(6 * H, { airtimeRxPct: 0, airtimeTxPct: 1080 })]; + const opt = airtimeOption(bucketed, colors, 6 * H) as Record; + expect(opt.series[0].data).toEqual([[0, 1], [6 * H, 0]]); + expect(opt.series[1].data).toEqual([[0, 0], [6 * H, 5]]); + }); + + it("labels the axis in percent", () => { + const opt = airtimeOption(points, colors, null) as Record; + expect(opt.yAxis.axisLabel.formatter).toContain("%"); + }); +}); + +const heard = (t: number, p: Partial = {}): ActivityPoint => ({ + t, + observations: 0, + airtimeMs: 0, + snrAvg: null, + snrMin: null, + rssiAvg: null, + ...p, +}); + +const window = { start: 0, end: 4 * H }; + +describe("busyOption", () => { + const points = [heard(0, { airtimeMs: 36_000 }), heard(H, { airtimeMs: null }), heard(2 * H, { airtimeMs: 0 })]; + + it("charts on-air time as percent of each bucket, keeping uncosted buckets as gaps", () => { + const opt = busyOption(points, colors, H, window) as Record; + expect(opt.series[0].data).toEqual([[0, 1], [H, null], [2 * H, 0]]); + expect(opt.yAxis.axisLabel.formatter).toContain("%"); + }); + + it("pins the time axis to the selected window so silence stays visible", () => { + const opt = busyOption(points, colors, H, window) as Record; + expect(opt.xAxis.min).toBe(0); + expect(opt.xAxis.max).toBe(4 * H); + }); + + it("prorates the bucket still in progress by the time elapsed so far", () => { + const full = [heard(0, { airtimeMs: 36_000 }), heard(H, { airtimeMs: 36_000 }), heard(2 * H, { airtimeMs: 36_000 })]; + const opt = busyOption(full, colors, H, { start: 0, end: 2 * H + 1_800_000 }) as Record; + expect(opt.series[0].data).toEqual([[0, 1], [H, 1], [2 * H, 2]]); + }); + + it("emits only gaps when the bucket width is unknown", () => { + const opt = busyOption(points, colors, null, window) as Record; + expect(opt.series[0].data).toEqual([[0, null], [H, null], [2 * H, null]]); + }); +}); + +describe("heardOption", () => { + it("charts the observation count per bucket over the window", () => { + const opt = heardOption([heard(0, { observations: 7 }), heard(H, { observations: 0 })], colors, window) as Record; + expect(opt.series[0].data).toEqual([[0, 7], [H, 0]]); + expect(opt.xAxis.min).toBe(0); + expect(opt.xAxis.max).toBe(4 * H); + }); +}); + +describe("snrHeardOption", () => { + const points = [heard(0, { snrAvg: 6.2, snrMin: -3 }), heard(H), heard(2 * H, { snrAvg: 4, snrMin: 1 })]; + + it("charts the average and the worst SNR as two series, leaving quiet buckets as gaps", () => { + const opt = snrHeardOption(points, colors, window) as Record; + expect(opt.series.map((s: { name: string }) => s.name)).toEqual(["Avg", "Min"]); + expect(opt.series[0].data).toEqual([[0, 6.2], [H, null], [2 * H, 4]]); + expect(opt.series[1].data).toEqual([[0, -3], [H, null], [2 * H, 1]]); + expect(opt.series[0].connectNulls).toBe(false); + expect(opt.xAxis.max).toBe(4 * H); }); }); diff --git a/tests/features/stats/transforms.test.ts b/tests/features/stats/transforms.test.ts new file mode 100644 index 00000000..1f35c565 --- /dev/null +++ b/tests/features/stats/transforms.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from "vitest"; +import { intervalToMs, airtimePctSeries, latestAirtimePct, fillActivity, busyPct, payloadBarItems } from "../../../src/features/stats/transforms"; +import type { ActivityPoint, TelemetryPoint } from "../../../src/features/stats/types"; + +const H = 3_600_000; + +const point = (t: number, p: Partial): TelemetryPoint => ({ + t, + batteryMv: null, + airtimeTxPct: null, + airtimeRxPct: null, + noiseFloorDb: null, + uptimeSeconds: null, + queueLength: null, + receiveErrors: null, + ...p, +}); + +const activity = (t: number, p: Partial = {}): ActivityPoint => ({ + t, + observations: 0, + airtimeMs: 0, + snrAvg: null, + snrMin: null, + rssiAvg: null, + ...p, +}); + +describe("intervalToMs", () => { + it("parses the minute and hour intervals the server accepts", () => { + expect(intervalToMs("5m")).toBe(300_000); + expect(intervalToMs("15m")).toBe(900_000); + expect(intervalToMs("1h")).toBe(H); + expect(intervalToMs("6h")).toBe(6 * H); + expect(intervalToMs("24h")).toBe(24 * H); + }); + + it("is null for anything else", () => { + expect(intervalToMs("")).toBeNull(); + expect(intervalToMs("1d")).toBeNull(); + expect(intervalToMs("h")).toBeNull(); + }); +}); + +describe("airtimePctSeries", () => { + // raw points carry cumulative on-air seconds; the series is the increase as a percent of the + // wall-clock gap between the two reports + const raw = [ + point(0, { airtimeRxPct: 10, airtimeTxPct: 4 }), + point(H, { airtimeRxPct: 46, airtimeTxPct: 4 }), // +36 s over 1 h + point(3 * H, { airtimeRxPct: 40, airtimeTxPct: 76 }), // rx reset, tx +72 s over 2 h + ]; + + it("divides each cumulative delta by the real gap to the previous report", () => { + expect(airtimePctSeries(raw, "airtimeRxPct", null)).toEqual([[H, 1], [3 * H, 0]]); + expect(airtimePctSeries(raw, "airtimeTxPct", null)).toEqual([[H, 0], [3 * H, 1]]); + }); + + it("leaves a gap when either side of a delta is missing", () => { + const withHole = [point(0, { airtimeRxPct: 10 }), point(H, { airtimeRxPct: null }), point(2 * H, { airtimeRxPct: 82 })]; + expect(airtimePctSeries(withHole, "airtimeRxPct", null)).toEqual([[H, null], [2 * H, null]]); + }); + + it("divides bucketed deltas by the bucket width", () => { + const bucketed = [point(0, { airtimeRxPct: 216 }), point(6 * H, { airtimeRxPct: 1080 })]; + expect(airtimePctSeries(bucketed, "airtimeRxPct", 6 * H)).toEqual([[0, 1], [6 * H, 5]]); + }); + + it("rounds to three decimals so tooltips stay readable", () => { + const pts = [point(0, { airtimeRxPct: 0 }), point(H, { airtimeRxPct: 1 })]; // 1 s / 3600 s + expect(airtimePctSeries(pts, "airtimeRxPct", null)).toEqual([[H, 0.028]]); + }); +}); + +describe("latestAirtimePct", () => { + it("reports the last delta for raw points", () => { + const raw = [point(0, { airtimeRxPct: 10, airtimeTxPct: 4 }), point(H, { airtimeRxPct: 46, airtimeTxPct: 76 })]; + expect(latestAirtimePct(raw, null)).toEqual({ rx: 1, tx: 2 }); + }); + + it("reports the last bucket for bucketed points", () => { + const bucketed = [point(0, { airtimeRxPct: 1, airtimeTxPct: 1 }), point(6 * H, { airtimeRxPct: 216, airtimeTxPct: 0 })]; + expect(latestAirtimePct(bucketed, 6 * H)).toEqual({ rx: 1, tx: 0 }); + }); + + it("is null when there is nothing to difference", () => { + expect(latestAirtimePct([], null)).toEqual({ rx: null, tx: null }); + expect(latestAirtimePct([point(0, { airtimeRxPct: 10 })], null)).toEqual({ rx: null, tx: null }); + }); +}); + +describe("fillActivity", () => { + const I = 900_000; + + it("emits every bucket in the window, zero where the server sent nothing", () => { + const heard = activity(I, { observations: 3, airtimeMs: 1200, snrAvg: 5.5, snrMin: -2, rssiAvg: -90 }); + const out = fillActivity([heard], I, { start: 0, end: 4 * I }); + expect(out.map((p) => p.t)).toEqual([0, I, 2 * I, 3 * I, 4 * I]); + expect(out[1]).toEqual(heard); + expect(out[0]).toEqual(activity(0)); + expect(out[4]).toEqual(activity(4 * I)); + }); + + it("starts at the first complete bucket and ends at the one in progress", () => { + // the server rounds the window start up to the next bucket, so a floored first bucket would be a fake zero + const out = fillActivity([], I, { start: I + 1, end: 3 * I - 1 }); + expect(out.map((p) => p.t)).toEqual([2 * I]); + }); + + it("keeps a server bucket newer than the window end rather than dropping it", () => { + // a client clock behind the server must not hide the newest real bucket + const out = fillActivity([activity(3 * I, { observations: 2 })], I, { start: 0, end: 2 * I + 1 }); + expect(out.map((p) => p.t)).toEqual([0, I, 2 * I, 3 * I]); + expect(out[3]!.observations).toBe(2); + }); + + it("drops server points outside the window", () => { + const out = fillActivity([activity(9 * I, { observations: 1 })], I, { start: 0, end: 2 * I }); + expect(out.every((p) => p.observations === 0)).toBe(true); + }); +}); + +describe("busyPct", () => { + it("is the share of the bucket spent on air, to three decimals", () => { + expect(busyPct(9_000, 900_000)).toBe(1); + expect(busyPct(1_234, 900_000)).toBe(0.137); + }); + + it("is null when the bucket could not be costed", () => { + expect(busyPct(null, 900_000)).toBeNull(); + }); +}); + +describe("payloadBarItems", () => { + it("lowercases the type names and orders by count for the bar chart", () => { + const items = payloadBarItems([ + { payloadType: 5, payloadTypeName: "GRP_TXT", count: 3 }, + { payloadType: 4, payloadTypeName: "ADVERT", count: 9 }, + ]); + expect(items).toEqual([{ name: "advert", value: 9 }, { name: "grp_txt", value: 3 }]); + }); +}); diff --git a/tests/features/stats/useLiveStats.test.tsx b/tests/features/stats/useLiveStats.test.tsx index 1d5df8c2..45a49094 100644 --- a/tests/features/stats/useLiveStats.test.tsx +++ b/tests/features/stats/useLiveStats.test.tsx @@ -2,10 +2,10 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { useLiveOverview } from "../../../src/features/stats/useLiveStats"; +import { useLiveOverview, useLiveObserver } from "../../../src/features/stats/useLiveStats"; import type { StatsOverview } from "../../../src/features/stats/types"; import type { WsManager } from "../../../src/api/ws-manager"; -import type { WsPacketObservation } from "../../../src/types/ws"; +import type { WsObserverStatus, WsPacketObservation } from "../../../src/types/ws"; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), @@ -66,3 +66,28 @@ describe("useLiveOverview", () => { expect(after?.totalObservations).toBe(202); }); }); + +describe("useLiveObserver", () => { + it("refreshes the selected observer's telemetry, but not its heard activity, on a status event", () => { + const qc = new QueryClient(); + qc.setQueryData(["observer-telemetry", "obs-1", "24h", "1h"], { range: "24h", interval: "1h", points: [] }); + qc.setQueryData(["observer-activity", "obs-1", "24h"], { range: "24h", interval: "15m", radio: null, payloadTypes: [], points: [] }); + const handlers: Array<(d: WsObserverStatus["data"]) => void> = []; + const manager = { + onObserverStatus: (h: (d: WsObserverStatus["data"]) => void) => { + handlers.push(h); + return () => {}; + }, + } as unknown as WsManager; + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook(() => useLiveObserver(manager, "obs-1", "24h"), { wrapper }); + + handlers.forEach((h) => h({ observerId: "obs-1" } as WsObserverStatus["data"])); + + expect(qc.getQueryState(["observer-telemetry", "obs-1", "24h", "1h"])?.isInvalidated).toBe(true); + // heard activity changes with packets, not status, and refreshes on its own interval + expect(qc.getQueryState(["observer-activity", "obs-1", "24h"])?.isInvalidated).toBe(false); + }); +}); diff --git a/tests/features/stats/useTelemetry.test.ts b/tests/features/stats/useTelemetry.test.ts new file mode 100644 index 00000000..9dd576f4 --- /dev/null +++ b/tests/features/stats/useTelemetry.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from "vitest"; +import { activityParamsFor, activityRefetchInterval } from "../../../src/features/stats/useTelemetry"; +import { ApiError } from "../../../src/api/client"; + +describe("activityParamsFor", () => { + it("pairs each stats range with the bucket the charts are sized for", () => { + expect(activityParamsFor("24h")).toEqual({ range: "24h", interval: "15m" }); + expect(activityParamsFor("7d")).toEqual({ range: "168h", interval: "1h" }); + expect(activityParamsFor("30d")).toEqual({ range: "720h", interval: "6h" }); + }); +}); + +describe("activityRefetchInterval", () => { + it("polls every minute so the right edge of the heard charts keeps moving", () => { + expect(activityRefetchInterval(null)).toBe(60_000); + expect(activityRefetchInterval(new Error("network"))).toBe(60_000); + }); + + it("stops polling once the server has said it has no activity endpoint", () => { + expect(activityRefetchInterval(new ApiError(404, "unknown", "not found"))).toBe(false); + }); +}); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index db25110e..e03bb640 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -1,5 +1,7 @@ import { describe, it, expect } from "vitest"; import { + formatRadio, + formatRadioParts, formatHex, formatAbsolute, timeAgoMs, @@ -155,3 +157,19 @@ describe("formatRatePerDay", () => { expect(formatRatePerDay(undefined, 7 * DAY_MS)).toBe("—"); }); }); + +describe("formatRadioParts", () => { + it("lists frequency, spreading factor, bandwidth and coding rate in the observer panel's order", () => { + expect(formatRadioParts({ freqMhz: 910.525, sf: 7, bwKhz: 62.5, cr: 5 })).toBe("910.525 MHz · SF7 · 62.5 kHz · CR 4/5"); + }); + + it("leaves out whatever is unknown and is null when nothing is", () => { + expect(formatRadioParts({ freqMhz: 910.525, sf: 7, bwKhz: 62.5 })).toBe("910.525 MHz · SF7 · 62.5 kHz"); + expect(formatRadioParts({ sf: 11 })).toBe("SF11"); + expect(formatRadioParts({})).toBeNull(); + }); + + it("backs the compact-string formatter so both read the same", () => { + expect(formatRadio("915,250,11")).toBe(formatRadioParts({ freqMhz: 915, sf: 11, bwKhz: 250 })); + }); +}); From 5ec0e19eb491e88ec02e488e221e03dbeea0c1b4 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 10 Sep 2026 07:13:04 -0400 Subject: [PATCH 091/163] stats: read renamed telemetry airtime fields (airtimeTxSecs/RxSecs) --- src/features/stats/ObserverTab.tsx | 2 +- src/features/stats/chartOptions.ts | 4 ++-- src/features/stats/transforms.ts | 10 +++++----- src/features/stats/types.ts | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index 639fb067..a90fe0a6 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -258,7 +258,7 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan option={airtime} isLoading={telemetry.isLoading} isError={telemetry.isError} - isEmpty={missing((p) => p.airtimeTxPct, (p) => p.airtimeRxPct)} + isEmpty={missing((p) => p.airtimeTxSecs, (p) => p.airtimeRxSecs)} />
p.batteryMv)} /> diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index ca5d7902..b4154fdb 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -324,8 +324,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs xAxis: timeAxis(c), yAxis: percentAxis(c), series: [ - { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxPct", bucketMs), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, - { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxPct", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, + { name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } }, + { name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } }, ], }; } diff --git a/src/features/stats/transforms.ts b/src/features/stats/transforms.ts index 4f76bc25..cf8fd4d8 100644 --- a/src/features/stats/transforms.ts +++ b/src/features/stats/transforms.ts @@ -37,8 +37,8 @@ export function hasTelemetry(points: TelemetryPoint[]): boolean { return points.some( (p) => live(p.batteryMv) || - live(p.airtimeTxPct) || - live(p.airtimeRxPct) || + live(p.airtimeTxSecs) || + live(p.airtimeRxSecs) || live(p.noiseFloorDb) || live(p.uptimeSeconds) || live(p.queueLength) || @@ -59,9 +59,9 @@ function pct(seconds: number, windowSeconds: number): number { return Math.round(((seconds * 100) / windowSeconds) * 1000) / 1000; } -type AirtimeKey = "airtimeRxPct" | "airtimeTxPct"; +type AirtimeKey = "airtimeRxSecs" | "airtimeTxSecs"; -// The values are on-air seconds despite the field name: cumulative on raw 1h points, per-bucket on +// The values are on-air seconds: cumulative on raw 1h points, per-bucket on // bucketed ones. Chart the increase as a percent of the elapsed time, clamped at 0 on counter resets. export function airtimePctSeries(points: TelemetryPoint[], key: AirtimeKey, bucketMs: number | null): [number, number | null][] { if (bucketMs != null) { @@ -88,7 +88,7 @@ export function latestAirtimePct(points: TelemetryPoint[], bucketMs: number | nu const s = airtimePctSeries(points, key, bucketMs); return s.length ? s[s.length - 1]![1] : null; }; - return { rx: last("airtimeRxPct"), tx: last("airtimeTxPct") }; + return { rx: last("airtimeRxSecs"), tx: last("airtimeTxSecs") }; } // The server skips empty buckets; fill them so a quiet stretch draws as zero instead of a skipped line. diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 3d4138e6..628d7879 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -99,8 +99,8 @@ export interface ScopeStats { export interface TelemetryPoint { t: number; // epoch ms batteryMv: number | null; - airtimeTxPct: number | null; // on-air seconds despite the name: cumulative on 1h points, per-bucket otherwise - airtimeRxPct: number | null; + airtimeTxSecs: number | null; // cumulative since boot on 1h points, per-bucket delta otherwise + airtimeRxSecs: number | null; noiseFloorDb: number | null; uptimeSeconds: number | null; queueLength: number | null; From 205dad8506dcce33a3889948e4995019bead6592 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 10 Sep 2026 09:17:18 -0400 Subject: [PATCH 092/163] stats tests: follow the airtime field rename --- tests/features/stats/ObserverTab.test.tsx | 8 +++--- tests/features/stats/chart-options.test.ts | 12 ++++---- tests/features/stats/has-telemetry.test.ts | 4 +-- tests/features/stats/transforms.test.ts | 32 +++++++++++----------- 4 files changed, 28 insertions(+), 28 deletions(-) diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index 1e60110f..a6006f82 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -63,8 +63,8 @@ const H = 3_600_000; const point = (t: number, p: Partial): TelemetryPoint => ({ t, batteryMv: null, - airtimeTxPct: null, - airtimeRxPct: null, + airtimeTxSecs: null, + airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, @@ -75,7 +75,7 @@ const point = (t: number, p: Partial): TelemetryPoint => ({ const telemetry: ObserverTelemetry = { range: "24h", interval: "1h", - points: [point(0, { airtimeRxPct: 100, airtimeTxPct: 10, batteryMv: 4100 }), point(H, { airtimeRxPct: 154, airtimeTxPct: 46, batteryMv: 4100 })], + points: [point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 })], }; const activity: ObserverActivity = { @@ -135,7 +135,7 @@ describe("ObserverTab", () => { }); it("leaves the header airtime stat out when the observer reports no telemetry", () => { - const zero = point(0, { airtimeRxPct: 0, airtimeTxPct: 0 }); + const zero = point(0, { airtimeRxSecs: 0, airtimeTxSecs: 0 }); telemetryResult.data = { ...telemetry, points: [zero, { ...zero, t: H }] }; renderTab(); expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument(); diff --git a/tests/features/stats/chart-options.test.ts b/tests/features/stats/chart-options.test.ts index 3688c134..949f2d30 100644 --- a/tests/features/stats/chart-options.test.ts +++ b/tests/features/stats/chart-options.test.ts @@ -133,8 +133,8 @@ describe("leaderboardOption", () => { const point = (t: number, p: Partial): TelemetryPoint => ({ t, batteryMv: null, - airtimeTxPct: null, - airtimeRxPct: null, + airtimeTxSecs: null, + airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, @@ -146,9 +146,9 @@ const H = 3_600_000; describe("airtimeOption", () => { const points = [ - point(0, { airtimeRxPct: 10, airtimeTxPct: 4 }), - point(H, { airtimeRxPct: 46, airtimeTxPct: 4 }), // +36 s over 1 h → 1% - point(3 * H, { airtimeRxPct: 40, airtimeTxPct: 76 }), // RX dips → clamp at 0; TX +72 s over 2 h → 1% + point(0, { airtimeRxSecs: 10, airtimeTxSecs: 4 }), + point(H, { airtimeRxSecs: 46, airtimeTxSecs: 4 }), // +36 s over 1 h → 1% + point(3 * H, { airtimeRxSecs: 40, airtimeTxSecs: 76 }), // RX dips → clamp at 0; TX +72 s over 2 h → 1% ]; it("charts raw counters as percent of the wall-clock gap between reports", () => { @@ -159,7 +159,7 @@ describe("airtimeOption", () => { }); it("charts bucketed deltas as percent of the bucket width", () => { - const bucketed = [point(0, { airtimeRxPct: 216, airtimeTxPct: 0 }), point(6 * H, { airtimeRxPct: 0, airtimeTxPct: 1080 })]; + const bucketed = [point(0, { airtimeRxSecs: 216, airtimeTxSecs: 0 }), point(6 * H, { airtimeRxSecs: 0, airtimeTxSecs: 1080 })]; const opt = airtimeOption(bucketed, colors, 6 * H) as Record; expect(opt.series[0].data).toEqual([[0, 1], [6 * H, 0]]); expect(opt.series[1].data).toEqual([[0, 0], [6 * H, 5]]); diff --git a/tests/features/stats/has-telemetry.test.ts b/tests/features/stats/has-telemetry.test.ts index 419301cb..7093b3f5 100644 --- a/tests/features/stats/has-telemetry.test.ts +++ b/tests/features/stats/has-telemetry.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { hasTelemetry } from "../../../src/features/stats/transforms"; import type { TelemetryPoint } from "../../../src/features/stats/types"; -const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }); +const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxSecs: null, airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }); describe("hasTelemetry", () => { it("is false for no points", () => { @@ -18,7 +18,7 @@ describe("hasTelemetry", () => { }); it("is false when every metric is zero (bots / MQTT bridges report all-zero rows)", () => { - const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxPct: 0, airtimeRxPct: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 }; + const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxSecs: 0, airtimeRxSecs: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 }; expect(hasTelemetry([allZero, allZero])).toBe(false); }); }); diff --git a/tests/features/stats/transforms.test.ts b/tests/features/stats/transforms.test.ts index 1f35c565..cca413a2 100644 --- a/tests/features/stats/transforms.test.ts +++ b/tests/features/stats/transforms.test.ts @@ -7,8 +7,8 @@ const H = 3_600_000; const point = (t: number, p: Partial): TelemetryPoint => ({ t, batteryMv: null, - airtimeTxPct: null, - airtimeRxPct: null, + airtimeTxSecs: null, + airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, @@ -46,46 +46,46 @@ describe("airtimePctSeries", () => { // raw points carry cumulative on-air seconds; the series is the increase as a percent of the // wall-clock gap between the two reports const raw = [ - point(0, { airtimeRxPct: 10, airtimeTxPct: 4 }), - point(H, { airtimeRxPct: 46, airtimeTxPct: 4 }), // +36 s over 1 h - point(3 * H, { airtimeRxPct: 40, airtimeTxPct: 76 }), // rx reset, tx +72 s over 2 h + point(0, { airtimeRxSecs: 10, airtimeTxSecs: 4 }), + point(H, { airtimeRxSecs: 46, airtimeTxSecs: 4 }), // +36 s over 1 h + point(3 * H, { airtimeRxSecs: 40, airtimeTxSecs: 76 }), // rx reset, tx +72 s over 2 h ]; it("divides each cumulative delta by the real gap to the previous report", () => { - expect(airtimePctSeries(raw, "airtimeRxPct", null)).toEqual([[H, 1], [3 * H, 0]]); - expect(airtimePctSeries(raw, "airtimeTxPct", null)).toEqual([[H, 0], [3 * H, 1]]); + expect(airtimePctSeries(raw, "airtimeRxSecs", null)).toEqual([[H, 1], [3 * H, 0]]); + expect(airtimePctSeries(raw, "airtimeTxSecs", null)).toEqual([[H, 0], [3 * H, 1]]); }); it("leaves a gap when either side of a delta is missing", () => { - const withHole = [point(0, { airtimeRxPct: 10 }), point(H, { airtimeRxPct: null }), point(2 * H, { airtimeRxPct: 82 })]; - expect(airtimePctSeries(withHole, "airtimeRxPct", null)).toEqual([[H, null], [2 * H, null]]); + const withHole = [point(0, { airtimeRxSecs: 10 }), point(H, { airtimeRxSecs: null }), point(2 * H, { airtimeRxSecs: 82 })]; + expect(airtimePctSeries(withHole, "airtimeRxSecs", null)).toEqual([[H, null], [2 * H, null]]); }); it("divides bucketed deltas by the bucket width", () => { - const bucketed = [point(0, { airtimeRxPct: 216 }), point(6 * H, { airtimeRxPct: 1080 })]; - expect(airtimePctSeries(bucketed, "airtimeRxPct", 6 * H)).toEqual([[0, 1], [6 * H, 5]]); + const bucketed = [point(0, { airtimeRxSecs: 216 }), point(6 * H, { airtimeRxSecs: 1080 })]; + expect(airtimePctSeries(bucketed, "airtimeRxSecs", 6 * H)).toEqual([[0, 1], [6 * H, 5]]); }); it("rounds to three decimals so tooltips stay readable", () => { - const pts = [point(0, { airtimeRxPct: 0 }), point(H, { airtimeRxPct: 1 })]; // 1 s / 3600 s - expect(airtimePctSeries(pts, "airtimeRxPct", null)).toEqual([[H, 0.028]]); + const pts = [point(0, { airtimeRxSecs: 0 }), point(H, { airtimeRxSecs: 1 })]; // 1 s / 3600 s + expect(airtimePctSeries(pts, "airtimeRxSecs", null)).toEqual([[H, 0.028]]); }); }); describe("latestAirtimePct", () => { it("reports the last delta for raw points", () => { - const raw = [point(0, { airtimeRxPct: 10, airtimeTxPct: 4 }), point(H, { airtimeRxPct: 46, airtimeTxPct: 76 })]; + const raw = [point(0, { airtimeRxSecs: 10, airtimeTxSecs: 4 }), point(H, { airtimeRxSecs: 46, airtimeTxSecs: 76 })]; expect(latestAirtimePct(raw, null)).toEqual({ rx: 1, tx: 2 }); }); it("reports the last bucket for bucketed points", () => { - const bucketed = [point(0, { airtimeRxPct: 1, airtimeTxPct: 1 }), point(6 * H, { airtimeRxPct: 216, airtimeTxPct: 0 })]; + const bucketed = [point(0, { airtimeRxSecs: 1, airtimeTxSecs: 1 }), point(6 * H, { airtimeRxSecs: 216, airtimeTxSecs: 0 })]; expect(latestAirtimePct(bucketed, 6 * H)).toEqual({ rx: 1, tx: 0 }); }); it("is null when there is nothing to difference", () => { expect(latestAirtimePct([], null)).toEqual({ rx: null, tx: null }); - expect(latestAirtimePct([point(0, { airtimeRxPct: 10 })], null)).toEqual({ rx: null, tx: null }); + expect(latestAirtimePct([point(0, { airtimeRxSecs: 10 })], null)).toEqual({ rx: null, tx: null }); }); }); From 8d963a59b3c683391d2c4bbb427d4a1d933fbd40 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 10 Sep 2026 09:17:18 -0400 Subject: [PATCH 093/163] Apply dependabot bumps; migrate to maplibre-gl 6 Named imports, Vite worker URL via setWorkerUrl, and the single missing-image resolver replacing the styleimagemissing listeners. --- package-lock.json | 309 +++++++++++++--------------- package.json | 6 +- src/features/map/MapView.tsx | 4 +- src/features/map/PacketPathMap.tsx | 14 +- src/features/map/maplibre-worker.ts | 6 + src/features/map/node-icons.ts | 2 +- src/features/map/useMapLibre.ts | 28 +-- src/features/map/useMapNodes.ts | 15 +- 8 files changed, 183 insertions(+), 201 deletions(-) create mode 100644 src/features/map/maplibre-worker.ts diff --git a/package-lock.json b/package-lock.json index 5586260e..a57c9149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,10 +13,10 @@ "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", "echarts": "6.1.0", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.9.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-router-dom": "^7.15.1" + "react-router-dom": "^7.18.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -36,7 +36,7 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", - "vitest": "^4.1.7" + "vitest": "^4.1.11" } }, "node_modules/@adobe/css-tools": { @@ -821,11 +821,12 @@ } }, "node_modules/@mapbox/jsonlint-lines-primitives": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz", - "integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 22" } }, "node_modules/@mapbox/point-geometry": { @@ -841,47 +842,38 @@ "license": "BSD-2-Clause" }, "node_modules/@mapbox/unitbezier": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", - "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", "license": "BSD-2-Clause" }, "node_modules/@mapbox/vector-tile": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", - "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", "license": "BSD-3-Clause", "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", - "pbf": "^4.0.2" - } - }, - "node_modules/@mapbox/whoots-js": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", - "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", - "license": "ISC", - "engines": { - "node": ">=6.0.0" + "pbf": "^5.0.0" } }, "node_modules/@maplibre/geojson-vt": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz", - "integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", "license": "ISC", "dependencies": { - "kdbush": "^4.0.2" + "kdbush": "^4.1.0" } }, "node_modules/@maplibre/maplibre-gl-style-spec": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", - "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.2.tgz", + "integrity": "sha512-6J0vZqMZvRAJKtdWJdDGHEh1YJ2ZHG08/GOur8gCArYhO8ZkM//OXqtI2AAEz4jc2G+Wq4MfcePg8qNK5TZ4Kg==", "license": "ISC", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/jsonlint-lines-primitives": "^2.0.3", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", @@ -894,16 +886,10 @@ "gl-style-validate": "dist/gl-style-validate.mjs" } }, - "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", - "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", - "license": "BSD-2-Clause" - }, "node_modules/@maplibre/mlt": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.11.tgz", - "integrity": "sha512-dKvjKdITw9d0y3ndGkSqLUEpWCizMtdq8NB06cHohH/JZ2sJoM7dClR9wzJLUWykjbw9RXDFmhjjNBnNW27mzw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.1.tgz", + "integrity": "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg==", "license": "(MIT OR Apache-2.0)", "dependencies": { "@mapbox/point-geometry": "^1.1.0" @@ -920,18 +906,6 @@ "pbf": "^5.1.0" } }, - "node_modules/@maplibre/vt-pbf/node_modules/pbf": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz", - "integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==", - "license": "BSD-3-Clause", - "dependencies": { - "resolve-protobuf-schema": "^2.1.0" - }, - "bin": { - "pbf": "bin/pbf" - } - }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -2077,16 +2051,16 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.8.tgz", - "integrity": "sha512-h3nDO677RDLEGlBxyQ5CW8RlMThSKSRLUePLOx09gNIWRL40edgA1GCZSZgf1W55MFAG6/Sw14KeaAnqv0NKdQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz", + "integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "chai": "^6.2.2", "tinyrainbow": "^3.1.0" }, @@ -2095,13 +2069,13 @@ } }, "node_modules/@vitest/mocker": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.8.tgz", - "integrity": "sha512-LEiN/xe4OSIbKe9HQIp5OC24agGD9J5CnmMgsLohVVoOPWL9a2sBoR6VBx43jQZb7Kr1l4RCuyCJzcAa0+dojw==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz", + "integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.8", + "@vitest/spy": "4.1.11", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2122,9 +2096,9 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.8.tgz", - "integrity": "sha512-9GasEBxpZ1VYIpqHf/0+YGg121uSNwCKOJqIrTwWP/TB7DmFCiaBpNl3aPZzoLWfWkuqhbH8vJIVobZkvdo2cA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz", + "integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==", "dev": true, "license": "MIT", "dependencies": { @@ -2135,13 +2109,13 @@ } }, "node_modules/@vitest/runner": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.8.tgz", - "integrity": "sha512-EmVxeBAfMJvycdjd6Hm+RbFBbA9fKvo0Kx37hNpBYoYeavH3RNsBXWDooR1mgD52dCrxIIuP7UotpfiwOikvcg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz", + "integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.8", + "@vitest/utils": "4.1.11", "pathe": "^2.0.3" }, "funding": { @@ -2149,14 +2123,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.8.tgz", - "integrity": "sha512-acfZboRmAIf05DEKcBQy33VXojFJjtUdLyo7oOmV9kebb2xdU01UknNiPuPZoJZQyO7DF0gZdTGTpeAzET9QPQ==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz", + "integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/pretty-format": "4.1.11", + "@vitest/utils": "4.1.11", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2165,9 +2139,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.8.tgz", - "integrity": "sha512-6EevtBp6OZOPF7bmz36HrGMeP3txgVSrgebWxHOafDXGkhIzfXK14f8KF6MuFfgXXUeHxmpD3BQxkV00/3s5mA==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz", + "integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==", "dev": true, "license": "MIT", "funding": { @@ -2175,13 +2149,13 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.8.tgz", - "integrity": "sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz", + "integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.8", + "@vitest/pretty-format": "4.1.11", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" }, @@ -2285,9 +2259,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.35", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz", - "integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==", + "version": "2.11.21", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.21.tgz", + "integrity": "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2298,32 +2272,31 @@ } }, "node_modules/bidi-js": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", - "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", - "dev": true, + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", "license": "MIT", "dependencies": { "require-from-string": "^2.0.2" } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.9", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", + "integrity": "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg==", "dev": true, "funding": [ { @@ -2341,11 +2314,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" @@ -2355,9 +2328,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001797", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz", - "integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "dev": true, "funding": [ { @@ -2523,9 +2496,9 @@ "peer": true }, "node_modules/earcut": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", - "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", "license": "ISC" }, "node_modules/echarts": { @@ -2539,9 +2512,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.371", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.371.tgz", - "integrity": "sha512-e9htk9mAYL6AzmkEhSvVVw7IWGSBJ/Bqdn2eRyRLrj1g6sncN4WbFt5qnILYoCktktr45pyjIrOiRvBThQ808w==", + "version": "1.5.426", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.426.tgz", + "integrity": "sha512-2Gcq6inCQs/AqfHP3f5ftCzk+pqeW2VlA1LgmPqEj2hfBO8HiZRspNYkD4HzFBe4u6lGqca4BspFr6Ix4Q400g==", "dev": true, "license": "ISC" }, @@ -3515,27 +3488,26 @@ } }, "node_modules/maplibre-gl": { - "version": "5.24.0", - "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", - "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "version": "6.9.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.9.0.tgz", + "integrity": "sha512-vFMwMK0Zs+NM/rOMSdtu8bO30DIexhBEVi5KC6f70/XtI+L/K2wC3LsDCAXFZ4s8ik5gAuDugfCNbpllpdJ9bA==", "license": "BSD-3-Clause", "dependencies": { - "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", - "@mapbox/tiny-sdf": "^2.1.0", - "@mapbox/unitbezier": "^0.0.1", - "@mapbox/vector-tile": "^2.0.4", - "@mapbox/whoots-js": "^3.1.0", - "@maplibre/geojson-vt": "^6.1.0", - "@maplibre/maplibre-gl-style-spec": "^24.8.1", - "@maplibre/mlt": "^1.1.8", - "@maplibre/vt-pbf": "^4.3.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.4.2", + "@maplibre/mlt": "^1.2.1", + "@maplibre/vt-pbf": "^4.3.2", "@types/geojson": "^7946.0.16", - "earcut": "^3.0.2", + "bidi-js": "^1.1.0", + "earcut": "^3.2.3", "gl-matrix": "^3.4.4", - "kdbush": "^4.0.2", + "kdbush": "^4.1.0", "murmurhash-js": "^1.0.0", - "pbf": "^4.0.1", + "pbf": "^5.1.2", "potpack": "^2.1.0", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" @@ -3604,9 +3576,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -3630,9 +3602,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.55", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.55.tgz", + "integrity": "sha512-mIrE/Cw9y+9Au6dS5vDKDhQza9YvG6w+ZrS6X+ZzA7yFW/soAeaups4Qzn1bL6g5FVy8WtP79+0j82oPIbqRjQ==", "dev": true, "license": "MIT", "engines": { @@ -3744,9 +3716,9 @@ "license": "MIT" }, "node_modules/pbf": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", - "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", "license": "BSD-3-Clause", "dependencies": { "resolve-protobuf-schema": "^2.1.0" @@ -3776,9 +3748,9 @@ } }, "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", "dev": true, "funding": [ { @@ -3796,7 +3768,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.18", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3888,9 +3860,9 @@ "peer": true }, "node_modules/react-router": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.17.0.tgz", - "integrity": "sha512-FDELK7rTMlCHO5+reyXsPlmfr7N1F91lPHsWYfMEGQm/KQ+F4JFM8jGoeQDmDvdTs93Fw9aSilH+uKRb4/jXvQ==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -3910,12 +3882,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.17.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.17.0.tgz", - "integrity": "sha512-fyU2yjGups/hE6Xz0I5ZYbVL8Gx29eCjgpHaRaTaVU+OOAdfRX05KsvyRm0GO8YQwOkhpU3MurW1jyMUJn+zSw==", + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", "license": "MIT", "dependencies": { - "react-router": "7.17.0" + "react-router": "7.18.2" }, "engines": { "node": ">=20.0.0" @@ -3943,7 +3915,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4163,9 +4134,9 @@ "license": "ISC" }, "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.1.tgz", + "integrity": "sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==", "dev": true, "license": "MIT", "engines": { @@ -4289,9 +4260,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", "dev": true, "license": "MIT", "engines": { @@ -4306,9 +4277,9 @@ "license": "MIT" }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "dev": true, "funding": [ { @@ -4425,19 +4396,19 @@ } }, "node_modules/vitest": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", - "integrity": "sha512-flY6ScbCIt9HThs+C5HS7jvGOB560DJtk/Z15IQROTA6zEy49Nh8T/dofWTQL+n3vswqn87sbJNiuqw1SDp5Ig==", + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz", + "integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.8", - "@vitest/mocker": "4.1.8", - "@vitest/pretty-format": "4.1.8", - "@vitest/runner": "4.1.8", - "@vitest/snapshot": "4.1.8", - "@vitest/spy": "4.1.8", - "@vitest/utils": "4.1.8", + "@vitest/expect": "4.1.11", + "@vitest/mocker": "4.1.11", + "@vitest/pretty-format": "4.1.11", + "@vitest/runner": "4.1.11", + "@vitest/snapshot": "4.1.11", + "@vitest/spy": "4.1.11", + "@vitest/utils": "4.1.11", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -4465,12 +4436,12 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.8", - "@vitest/browser-preview": "4.1.8", - "@vitest/browser-webdriverio": "4.1.8", - "@vitest/coverage-istanbul": "4.1.8", - "@vitest/coverage-v8": "4.1.8", - "@vitest/ui": "4.1.8", + "@vitest/browser-playwright": "4.1.11", + "@vitest/browser-preview": "4.1.11", + "@vitest/browser-webdriverio": "4.1.11", + "@vitest/coverage-istanbul": "4.1.11", + "@vitest/coverage-v8": "4.1.11", + "@vitest/ui": "4.1.11", "happy-dom": "*", "jsdom": "*", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" diff --git a/package.json b/package.json index 4cb16def..45276a2e 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", "echarts": "6.1.0", - "maplibre-gl": "^5.24.0", + "maplibre-gl": "^6.9.0", "react": "^19.2.6", "react-dom": "^19.2.6", - "react-router-dom": "^7.15.1" + "react-router-dom": "^7.18.2" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -44,6 +44,6 @@ "typescript": "~6.0.2", "typescript-eslint": "^8.59.2", "vite": "^8.0.12", - "vitest": "^4.1.7" + "vitest": "^4.1.11" } } diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 78710776..d89ff2a7 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -184,7 +184,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp }, [iatas, selectedIatas]); const borderData = useMapBordersData(borderIatas, borders); - const { containerRef, mapRef, isReady, error } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera); + const { containerRef, mapRef, isReady, error, nodeIconResolverRef } = useMapLibre(styleId, fitPoints, handleStyleError, initialCamera); const isDark = resolveMapStyle(styleId).dark; // drives marker theming + maplibre control chrome // Snapshot the current view (live camera + settings) into deep-link params for the copy button. @@ -205,7 +205,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp return { tab: "Map", ...buildMapParams(snapshot) }; }, [mapRef, clustered, typeFilter, neighborLines, styleId, packetFlow, borders]); - useMapNodes(mapRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); + useMapNodes(mapRef, nodeIconResolverRef, isReady, geojson, isDark, themeKey, clustered, onSelectNode, selectedNodeId, packetFlow, focusIds, `${regionKey}:${typeFilter}`); useMapNeighbors(mapRef, isReady, neighborEdges, themeKey); useMapBorders(mapRef, isReady, borderData, themeKey); useMapPacketFlow(mapRef, isReady, packetFlow, wsManager, themeKey, regionKey); diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index 87d6cea0..b421868d 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -1,8 +1,8 @@ // src/features/map/PacketPathMap.tsx import { useEffect, useRef, useState } from "react"; -import maplibregl from "maplibre-gl"; +import { Map as MapLibreMap, NavigationControl, AttributionControl, Popup, LngLatBounds } from "maplibre-gl"; +import "./maplibre-worker"; import type { - Map as MapLibreMap, GeoJSONSource, LineLayerSpecification, CircleLayerSpecification, @@ -38,7 +38,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { // build the map once (styleId is read at creation; the popup doesn't hot-swap basemaps) useEffect(() => { if (mapRef.current || !containerRef.current) return; - const map = new maplibregl.Map({ + const map = new MapLibreMap({ container: containerRef.current, style: resolveMapStyle(styleId).url, center: DEFAULT_CENTER, @@ -46,8 +46,8 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { attributionControl: false, }); mapRef.current = map; - map.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right"); - map.addControl(new maplibregl.AttributionControl({ compact: true })); + map.addControl(new NavigationControl({ showCompass: false }), "top-right"); + map.addControl(new AttributionControl({ compact: true })); // start the attribution as a bare (i) instead of the wide expanded bar it pops open on load — // on mobile that bar overlaps the observer list beneath the map (same trick as useMapLibre) const attrib = map.getContainer().querySelector(".maplibregl-ctrl-attrib"); @@ -69,7 +69,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { coords.textContent = `${lat.toFixed(5)}, ${lng.toFixed(5)}`; el.append(name, coords); // a fresh popup per click; closeOnClick removes the previous one on the same click - new maplibregl.Popup({ closeButton: false, closeOnClick: true, offset: 10 }) + new Popup({ closeButton: false, closeOnClick: true, offset: 10 }) .setLngLat([lng, lat]).setDOMContent(el).addTo(map); }); const onLoad = () => setReady(true); @@ -124,7 +124,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { (map.getSource(NODE_SOURCE) as GeoJSONSource).setData(points); if (bounds.length) { - const b = bounds.reduce((acc, p) => acc.extend(p), new maplibregl.LngLatBounds(bounds[0], bounds[0])); + const b = bounds.reduce((acc, p) => acc.extend(p), new LngLatBounds(bounds[0], bounds[0])); map.fitBounds(b, { padding: 60, maxZoom: IATA_ZOOM }); } }, [ready, paths, selectedKey]); diff --git a/src/features/map/maplibre-worker.ts b/src/features/map/maplibre-worker.ts new file mode 100644 index 00000000..6d090f9a --- /dev/null +++ b/src/features/map/maplibre-worker.ts @@ -0,0 +1,6 @@ +import { setWorkerUrl } from "maplibre-gl"; +import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url"; + +// maplibre 6 can't find its worker through Vite's module graph on its own; without this the map +// silently never fetches vector tiles. Import once from anything that constructs a Map. +setWorkerUrl(workerUrl); diff --git a/src/features/map/node-icons.ts b/src/features/map/node-icons.ts index af49be6e..628c9baa 100644 --- a/src/features/map/node-icons.ts +++ b/src/features/map/node-icons.ts @@ -8,7 +8,7 @@ import { } from "./types"; // Marker icons: per-type SVG + cluster hexagon, recolored and rasterized to maplibre images (unknown -// type = canvas ring). Async, so provided lazily via styleimagemissing in useMapNodes; re-colors on theme. +// type = canvas ring). Async, so provided lazily via the missing-image resolver in useMapNodes; re-colors on theme. // Glyph SVGs as raw text per type (+ observer variant). Marker style follows the basemap: filled // Glitch on dark, hollow Wireframe on light — which is exactly what isDark encodes. import.meta.glob diff --git a/src/features/map/useMapLibre.ts b/src/features/map/useMapLibre.ts index 08a8fea2..cca1a697 100644 --- a/src/features/map/useMapLibre.ts +++ b/src/features/map/useMapLibre.ts @@ -1,6 +1,7 @@ import { useEffect, useRef, useState } from "react"; -import maplibregl from "maplibre-gl"; -import type { Map as MapLibreMap, RasterDEMSourceSpecification } from "maplibre-gl"; +import { Map as MapLibreMap, NavigationControl, ScaleControl, AttributionControl, LngLatBounds } from "maplibre-gl"; +import type { RasterDEMSourceSpecification } from "maplibre-gl"; +import "./maplibre-worker"; import { DEM_TILES, DEM_ATTRIBUTION, @@ -78,6 +79,7 @@ export function useMapLibre( const lastGoodStyleIdRef = useRef(styleId); // last style that loaded; the revert target on a failed swap const hasLoadedRef = useRef(false); // a style has loaded at least once (distinguishes initial-load failure) const swapPendingRef = useRef(false); // a setStyle() basemap swap is in flight (awaiting style.load) + const nodeIconResolverRef = useRef<((id: string) => Promise) | null>(null); // filled by useMapNodes const onStyleErrorRef = useRef(onStyleError); const lastFitKeyRef = useRef(null); // last applied fit target; skips redundant re-fits const skipInitialFitRef = useRef(!!initialCamera); // let a deep-link camera win over the first fit @@ -103,7 +105,7 @@ export function useMapLibre( // open at the deep-link camera if one was given, else the default view; the fit effect frames the // selection once the style is ready (unless a deep-link camera suppresses that first fit) - const map = new maplibregl.Map({ + const map = new MapLibreMap({ container, style: resolveMapStyle(styleIdRef.current).url, center: initialCameraRef.current?.center ?? DEFAULT_CENTER, @@ -116,9 +118,9 @@ export function useMapLibre( mapRef.current = map; lastStyleIdRef.current = styleIdRef.current; - map.addControl(new maplibregl.NavigationControl({ visualizePitch: true }), "top-right"); - map.addControl(new maplibregl.ScaleControl({ unit: "metric" }), "bottom-left"); - map.addControl(new maplibregl.AttributionControl({ compact: true })); // bottom-right + map.addControl(new NavigationControl({ visualizePitch: true }), "top-right"); + map.addControl(new ScaleControl({ unit: "metric" }), "bottom-left"); + map.addControl(new AttributionControl({ compact: true })); // bottom-right // maplibre pops the compact attribution open the first time the basemap credit loads (it tacks // on .maplibregl-compact-show). Mark it .maplibregl-compact up front so it skips that and stays // a bare (i) on load — clicking it still opens the credit. @@ -141,14 +143,16 @@ export function useMapLibre( // "circle-11"), so maplibre warns on every load. Hand it a transparent 1x1 for anything that // isn't ours and the noise goes away — a missing icon already draws nothing, so the map looks // identical. Our own markers all start with "node-" and are rasterized by useMapNodes, so we - // leave those alone. This lives here (not in useMapNodes) so it's listening before the base + // leave those alone. This lives here (not in useMapNodes) so it's installed before the base // style's first paint, when those icons are first requested. - map.on("styleimagemissing", (e) => { - if (!e.id.startsWith("node-") && !map.hasImage(e.id)) map.addImage(e.id, new ImageData(1, 1)); + // maplibre allows one resolver per map, so node icons route through a slot useMapNodes fills. + map.setMissingStyleImageResolver((id) => { + if (id.startsWith("node-")) return nodeIconResolverRef.current?.(id); + if (!map.hasImage(id)) map.addImage(id, new ImageData(1, 1)); }); map.on("error", (e) => { - const err = e as { error?: Error; sourceId?: string; tile?: unknown }; + const err = e as unknown as { error?: Error; sourceId?: string; tile?: unknown }; // A single tile/source failure (one basemap or DEM tile timing out / 403 / a momentary network // blip) is transient and non-fatal — the rest of the map stays usable — so never blank the map // for it. maplibre tags tile/source errors with a tile/sourceId; style-level errors have neither. @@ -212,7 +216,7 @@ export function useMapLibre( const bounds = fitPoints.reduce( (b, p) => b.extend(p), - new maplibregl.LngLatBounds(fitPoints[0], fitPoints[0]), + new LngLatBounds(fitPoints[0], fitPoints[0]), ); map.fitBounds(bounds, { padding: 48, @@ -222,5 +226,5 @@ export function useMapLibre( }); }, [fitPoints, isReady]); - return { containerRef, mapRef, isReady, error }; + return { containerRef, mapRef, isReady, error, nodeIconResolverRef }; } diff --git a/src/features/map/useMapNodes.ts b/src/features/map/useMapNodes.ts index c4a78071..f674755d 100644 --- a/src/features/map/useMapNodes.ts +++ b/src/features/map/useMapNodes.ts @@ -98,6 +98,7 @@ const SPIDER_LEAVES_LAYOUT: SymbolLayerSpecification["layout"] = { // labels at high zoom). Like useMapLibre, the imperative work re-adds itself after every style switch. export function useMapNodes( mapRef: React.RefObject, + nodeIconResolverRef: React.RefObject<((id: string) => Promise) | null>, isReady: boolean, geojson: NodeFC, isDark: boolean, @@ -281,8 +282,9 @@ export function useMapNodes( }, [mapRef, isReady, isDark, clustered, themeKey]); // Supply and re-color the marker images. SVG glyphs rasterize async, so they're provided both - // proactively here and lazily on styleimagemissing. Re-runs on a theme/basemap/DPR change to - // re-rasterize; a basemap switch also drops the images via setStyle, which this then restores. + // proactively here and lazily through the map's missing-image resolver. Re-runs on a theme/ + // basemap/DPR change to re-rasterize; a basemap switch also drops the images via setStyle, which + // this then restores. useEffect(() => { const map = mapRef.current; if (!map || !isReady) return; @@ -297,12 +299,11 @@ export function useMapNodes( .catch(() => { /* an icon failed to rasterize; the layer simply draws nothing for that id */ }); - const onMissing = (e: { id: string }) => provide(e.id); - map.on("styleimagemissing", onMissing); + nodeIconResolverRef.current = provide; // A symbol won't draw until its icon is in, and adding one late doesn't redraw tiles that // already laid out — that's the "markers only show after I pan/zoom" bug. So once every icon // is ready, nudge the source to lay the markers out again (setData reloads the whole source). - // styleimagemissing still covers anything asked for before we get here. + // The resolver still covers anything asked for before we get here. Promise.all(MAP_ICON_IDS.map(provide)).then(() => { if (cancelled || mapRef.current !== map) return; const src = map.getSource(NODES_SOURCE_ID) as GeoJSONSource | undefined; @@ -310,9 +311,9 @@ export function useMapNodes( }); return () => { cancelled = true; - map.off("styleimagemissing", onMissing); + nodeIconResolverRef.current = null; }; - }, [mapRef, isReady, isDark, themeKey, dpr]); + }, [mapRef, nodeIconResolverRef, isReady, isDark, themeKey, dpr]); // Reflect the shared selection as a ring (mirrors the table's row highlight). Its own effect so // changing the selection doesn't rebuild the source/layers. From d51e0a997b33a6041f9b9b2f7c7243bac7c8d6d1 Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 10 Sep 2026 09:25:55 -0400 Subject: [PATCH 094/163] fix(packets): keep filtered history pageable without scrolling (#34) --- src/features/packets/PacketList.tsx | 3 +- src/features/packets/PacketVirtualList.tsx | 23 +++++++-- src/features/packets/usePackets.ts | 2 + tests/features/packets/PacketList.test.tsx | 1 + .../packets/PacketVirtualList.test.tsx | 49 ++++++++++++++++++- 5 files changed, 71 insertions(+), 7 deletions(-) diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 0340b6bb..d57d4803 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -61,6 +61,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati acknowledgeNewPackets, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, isError, @@ -198,7 +199,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati key={listResetKey} packets={packets} hasNextPage={hasNextPage} - isFetchingNextPage={isFetchingNextPage} + isFetching={isFetching} fetchNextPage={fetchNextPage} onScrollAwayFromTop={setIsScrolledAway} onAtTopChange={setIsAtTop} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index 4cc15ad3..e2d64a2c 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -16,7 +16,7 @@ import { interface PacketVirtualListProps { packets: PacketSummary[]; hasNextPage: boolean; - isFetchingNextPage: boolean; + isFetching: boolean; fetchNextPage: () => void; onScrollAwayFromTop: (isAway: boolean) => void; onAtTopChange: (isAtTop: boolean) => void; @@ -34,7 +34,7 @@ interface PacketVirtualListProps { export function PacketVirtualList({ packets, hasNextPage, - isFetchingNextPage, + isFetching, fetchNextPage, onScrollAwayFromTop, onAtTopChange, @@ -71,13 +71,13 @@ export function PacketVirtualList({ onScrollAwayFromTop(el.scrollTop > SCROLL_TOP_THRESHOLD_PX); onAtTopChange(atTopRef.current); - if (hasNextPage && !isFetchingNextPage) { + if (hasNextPage && !isFetching) { const distFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight; if (distFromBottom < SCROLL_BOTTOM_THRESHOLD_PX) { fetchNextPage(); } } - }, [hasNextPage, isFetchingNextPage, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); + }, [hasNextPage, isFetching, fetchNextPage, onScrollAwayFromTop, onAtTopChange]); // When rows are prepended at the top (a reveal on return-to-top, or a live packet while already // at the top), TanStack keeps the previously-top row anchored — which drifts the view off the @@ -152,6 +152,21 @@ export function PacketVirtualList({ ); })}
+ {packets.length === 0 && ( +

No matching packets loaded.

+ )} + {hasNextPage && ( +
+ +
+ )}
); } diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index 3b642a65..ef453ee2 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -206,6 +206,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi data: history, fetchNextPage, hasNextPage, + isFetching, isFetchingNextPage, isLoading, isError, @@ -251,6 +252,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi acknowledgeNewPackets, fetchNextPage, hasNextPage: hasNextPage ?? false, + isFetching, isFetchingNextPage, isLoading, isError, diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index d704489e..8366c68d 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -14,6 +14,7 @@ const basePackets = () => ({ acknowledgeNewPackets: () => {}, fetchNextPage: () => {}, hasNextPage: false, + isFetching: false, isFetchingNextPage: false, isLoading: false, isError: false, diff --git a/tests/features/packets/PacketVirtualList.test.tsx b/tests/features/packets/PacketVirtualList.test.tsx index 96f80288..8473f8bd 100644 --- a/tests/features/packets/PacketVirtualList.test.tsx +++ b/tests/features/packets/PacketVirtualList.test.tsx @@ -59,7 +59,7 @@ const many = (n: number) => Array.from({ length: n }, (_, i) => pkt(`AA${i}`)); function makeHandlers() { return { hasNextPage: false, - isFetchingNextPage: false, + isFetching: false, fetchNextPage: vi.fn(), onScrollAwayFromTop: vi.fn(), onAtTopChange: vi.fn(), @@ -167,6 +167,51 @@ describe("PacketVirtualList header", () => { }); describe("PacketVirtualList scrolling", () => { + it.each([0, 1])("can manually page with %i visible rows and no scrollbar", (count) => { + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + if (count === 0) expect(screen.getByText("No matching packets loaded.")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Load older packets" })); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(1); + + // A still-empty result after the page arrives must not start scanning further pages. + rerender(); + flushResize(); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(1); + fireEvent.click(screen.getByRole("button", { name: "Load older packets" })); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(2); + }); + + it("disables manual paging during a request and allows retry afterward", () => { + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + const pending = screen.getByRole("button", { name: "Loading packets..." }); + expect(pending).toBeDisabled(); + fireEvent.click(pending); + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + + rerender(); + fireEvent.click(screen.getByRole("button", { name: "Load older packets" })); + expect(handlers.fetchNextPage).toHaveBeenCalledTimes(1); + }); + + it("removes manual paging when the history cursor is exhausted", () => { + const handlers = makeHandlers(); + const { rerender } = render( + , + ); + expect(screen.getByRole("button", { name: "Load older packets" })).toBeEnabled(); + rerender(); + expect(screen.queryByRole("button", { name: "Load older packets" })).not.toBeInTheDocument(); + expect(handlers.fetchNextPage).not.toHaveBeenCalled(); + }); + it("pages when scrolled near the bottom", () => { const handlers = makeHandlers(); const { container } = render( @@ -183,7 +228,7 @@ describe("PacketVirtualList scrolling", () => { it("does not page while a page is already in flight", () => { const handlers = makeHandlers(); const { container } = render( - , + , ); const el = scroller(container); From 9ab1a44ae92b8862ae38479e5de28591d9f8956f Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 10 Sep 2026 09:26:05 -0400 Subject: [PATCH 095/163] fix(channels): load older directory pages without losing selection (#37) * fix(channels): page the directory without losing the open channel * fix(channels): prefer precise server page cursors --- src/api/client.ts | 14 +- src/features/channels/ChannelList.tsx | 103 +++++++---- src/features/channels/channel-filters.ts | 2 +- src/features/channels/types.ts | 6 + tests/App.packetUrlContract.test.tsx | 2 +- tests/api/client.test.ts | 13 +- tests/features/channels/ChannelList.test.tsx | 176 +++++++++++++++++++ 7 files changed, 274 insertions(+), 42 deletions(-) create mode 100644 tests/features/channels/ChannelList.test.tsx diff --git a/src/api/client.ts b/src/api/client.ts index b061f855..236d43d7 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,7 +1,7 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit"; import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; -import type { ChannelSummary, ChannelMessage } from "../features/channels/types"; +import type { ChannelPage, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; import type { @@ -114,16 +114,16 @@ export function getRegion(regionId: number): Promise { return request(`/regions/${regionId}`); } -// /channels only honors a singular `iata`, so a one-IATA region goes through it; multi-IATA regions -// still send `iatas` (ignored server-side, effectively global) until the backend supports it. -export async function getChannels(params?: { iatas?: string[]; limit?: number }): Promise { +// Preserve the server cursor rather than deriving it from the displayed channel order. +export function getChannels(params?: { iatas?: string[]; limit?: number; cursor?: number | string }): Promise { const iatas = params?.iatas ?? []; - const page = await request<{ items: ChannelSummary[] }>("/channels", { + return request("/channels", { iata: iatas.length === 1 ? iatas[0] : undefined, iatas: iatas.length > 1 ? iatasParam(iatas) : undefined, - limit: params?.limit, + limit: params?.limit ?? DEFAULT_PAGE_SIZE, + cursor: typeof params?.cursor === "number" ? params.cursor : undefined, + pageCursor: typeof params?.cursor === "string" ? params.cursor : undefined, }); - return page.items; } // Channel messages come back as { items } ordered id DESC, so the last row is the page's oldest diff --git a/src/features/channels/ChannelList.tsx b/src/features/channels/ChannelList.tsx index d215e5cd..12180f20 100644 --- a/src/features/channels/ChannelList.tsx +++ b/src/features/channels/ChannelList.tsx @@ -1,6 +1,8 @@ import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; +import { useInfiniteQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getChannels } from "../../api/client"; +import { isRateLimited } from "../../api/rate-limit"; +import { MAX_INFINITE_PAGES } from "../../lib/constants"; import { useRegion } from "../../hooks/useRegion"; import { useIsMobile } from "../../hooks/useMediaQuery"; import { useWsChannelMessageHandler } from "../../hooks/useWsHandlers"; @@ -9,7 +11,7 @@ import { ChannelSidebar } from "./ChannelSidebar"; import { ChannelFilterBar } from "./ChannelFilterBar"; import { MessagePanel } from "./MessagePanel"; import { filterChannels, type ChannelKeyFilter, type ChannelHashtagFilter } from "./channel-filters"; -import type { ChannelMessage, ChannelSummary } from "./types"; +import type { ChannelMessage, ChannelPage, ChannelSummary } from "./types"; import type { CursorPage } from "../../types/api"; import type { WsManager } from "../../api/ws-manager"; @@ -21,19 +23,23 @@ interface ChannelListProps { export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { const { iatas, regionKey } = useRegion(); const isMobile = useIsMobile(); - const [selectedId, setSelectedId] = useState(null); + // Keep the open channel available when a directory page is evicted or refreshed. + const [selection, setSelection] = useState(null); + const selectedId = selection?.id ?? null; const [heardCounts, setHeardCounts] = useState>({}); const [search, setSearch] = useState(""); const [searchField, setSearchField] = useState("name"); const [keyFilter, setKeyFilter] = useState(""); const [hashtagFilter, setHashtagFilter] = useState(""); const queryClient = useQueryClient(); + const refreshPending = useRef(false); const prevRegion = useRef(regionKey); useEffect(() => { if (prevRegion.current !== regionKey) { prevRegion.current = regionKey; - setSelectedId(null); + refreshPending.current = false; + setSelection(null); setHeardCounts({}); setSearch(""); setKeyFilter(""); @@ -41,21 +47,41 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { } }, [regionKey]); - const handleSelect = useCallback((id: number) => { - setSelectedId(id); - setHeardCounts({}); - }, []); - - const { data: channels, isLoading } = useQuery({ + const { data, isLoading, isFetching, isError, fetchNextPage, hasNextPage, refetch } = useInfiniteQuery({ queryKey: ["channels", regionKey], - queryFn: () => getChannels({ iatas }), + queryFn: ({ pageParam }) => getChannels({ iatas, cursor: pageParam }), + initialPageParam: undefined as number | string | undefined, + getNextPageParam: (last) => last.hasMore ? last.nextPageCursor ?? last.nextCursor ?? undefined : undefined, + maxPages: MAX_INFINITE_PAGES, staleTime: 60_000, }); + useEffect(() => { + if (!isFetching && refreshPending.current) { + refreshPending.current = false; + void queryClient.resetQueries({ queryKey: ["channels", regionKey], exact: true }); + } + }, [isFetching, queryClient, regionKey]); + + const channels = useMemo(() => { + const byId = new Map(); + for (const page of data?.pages ?? []) { + for (const channel of page.items) { + if (!byId.has(channel.id)) byId.set(channel.id, channel); + } + } + return [...byId.values()]; + }, [data]); + + const handleSelect = useCallback((id: number) => { + setSelection(channels.find((ch) => ch.id === id) ?? null); + setHeardCounts({}); + }, [channels]); + // "Public" pinned first, then named channels, then unnamed by most recent const sortedChannels = useMemo( () => - [...(channels ?? [])].sort((a, b) => { + [...channels].sort((a, b) => { const aPub = a.name === "Public" ? 1 : 0; const bPub = b.name === "Public" ? 1 : 0; if (aPub !== bPub) return bPub - aPub; @@ -71,27 +97,28 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { [sortedChannels, search, searchField, keyFilter, hashtagFilter], ); - // resolve against the full list so a selected channel keeps showing even when filtered out - const selectedChannel = sortedChannels.find((ch) => ch.id === selectedId) ?? null; + const selectedChannel = channels.find((ch) => ch.id === selectedId) ?? selection; const handleChannelMessage = useCallback( (data: ChannelMessage) => { - // bump lastSeen, or refetch the list if this is a channel we haven't seen yet - queryClient.setQueryData(["channels", regionKey], (old) => { - if (!old) return old; - const idx = old.findIndex((ch) => ch.channelHash === data.channelHash); - if (idx === -1) { - queryClient.invalidateQueries({ queryKey: ["channels", regionKey] }); - return old; - } - const updated = [...old]; - updated[idx] = { ...updated[idx]!, lastSeen: data.sentAt }; - return updated; - }); + const key = ["channels", regionKey]; + const cached = queryClient.getQueryData>(key); + const cachedChannels = cached?.pages.flatMap((p) => p.items) ?? []; + const known = cachedChannels.find((ch) => ch.channelHash === data.channelHash); + if (known) { + // Display timestamps may change; the server's page cursors must not. + queryClient.setQueryData>(key, (old) => old && ({ + ...old, + pages: old.pages.map((p) => ({ ...p, items: p.items.map((ch) => ch.id === known.id + ? { ...ch, lastSeen: Math.max(ch.lastSeen, data.sentAt) } : ch) })), + })); + } else if (!isRateLimited()) { + // An unknown live channel needs a fresh first page, not a replay of every loaded page. + if (queryClient.isFetching({ queryKey: key, exact: true })) refreshPending.current = true; + else void queryClient.resetQueries({ queryKey: key, exact: true }); + } - // use cache directly to avoid stale closure over selectedChannel - const cached = queryClient.getQueryData(["channels", regionKey]); - const selected = cached?.find((ch) => ch.id === selectedId); + const selected = cachedChannels.find((ch) => ch.id === selectedId) ?? selection; if (selected && data.channelHash === selected.channelHash) { // same message, multiple observer paths — count the reach setHeardCounts((prev) => ({ @@ -110,7 +137,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { ); } }, - [queryClient, selectedId, regionKey], + [queryClient, selectedId, selection, regionKey], ); useWsChannelMessageHandler(wsManager, handleChannelMessage); @@ -144,6 +171,20 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { onSelect={handleSelect} /> )} + {!isLoading && filteredChannels.length === 0 && ( +

No matching channels loaded.

+ )} + {isError &&

Could not load channels.

} + {(hasNextPage || isError) && ( + + )}
)} {(!isMobile || selectedChannel !== null) && ( @@ -153,7 +194,7 @@ export function ChannelList({ wsManager, onAnalyze }: ChannelListProps) { iatas={iatas} regionKey={regionKey} onAnalyze={onAnalyze} - onBack={isMobile ? () => setSelectedId(null) : undefined} + onBack={isMobile ? () => setSelection(null) : undefined} /> )}
diff --git a/src/features/channels/channel-filters.ts b/src/features/channels/channel-filters.ts index 9bb13feb..ad37ab3c 100644 --- a/src/features/channels/channel-filters.ts +++ b/src/features/channels/channel-filters.ts @@ -11,7 +11,7 @@ export interface ChannelFilters { hashtagFilter: ChannelHashtagFilter; } -// client-side channel-list filtering (the list is fully loaded; the backend has no text search) +// Filter loaded directory pages; the backend has no text-search parameter. export function filterChannels(channels: ChannelSummary[], filters: ChannelFilters): ChannelSummary[] { const q = filters.search.trim().toLowerCase(); return channels.filter((ch) => { diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index 6d7e9afb..927ba652 100644 --- a/src/features/channels/types.ts +++ b/src/features/channels/types.ts @@ -1,3 +1,9 @@ +import type { CursorPage } from "../../types/api"; + +export interface ChannelPage extends CursorPage { + nextPageCursor?: string; // opaque precise cursor; older servers only return nextCursor +} + export interface ChannelSummary { id: number; name: string | null; diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 83dc1679..c77d70e7 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -29,7 +29,7 @@ vi.mock("../src/api/client", () => ({ getRegion: async () => ({ id: 0, slug: "", displayName: "", iatas: [] }), getIatas: async () => [], getScopes: async () => [], - getChannels: async () => [], + getChannels: async () => ({ items: [], nextCursor: null, hasMore: false }), getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), })); diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 4709eba2..04ef04a7 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -204,6 +204,14 @@ describe("searchKnownRoutes", () => { }); describe("getChannels", () => { + it.each([1234, "v1:1700000000000123:50"])("sends cursor %s using the compatible parameter", async (cursor) => { + const getUrl = mockFetchOnce({ items: [], hasMore: false }); + await getChannels({ cursor }); + const params = new URL(getUrl()).searchParams; + expect(params.get(typeof cursor === "string" ? "pageCursor" : "cursor")).toBe(String(cursor)); + expect(params.has(typeof cursor === "string" ? "cursor" : "pageCursor")).toBe(false); + }); + const channel: ChannelSummary = { id: 1, name: "Public", @@ -214,7 +222,8 @@ describe("getChannels", () => { }; it("sends a single-IATA region as the singular iata param the server honors", async () => { - const getUrl = mockFetchOnce({ items: [channel] }); + const page = { items: [channel], nextCursor: 900, hasMore: true }; + const getUrl = mockFetchOnce(page); const channels = await getChannels({ iatas: ["YYZ"] }); @@ -222,7 +231,7 @@ describe("getChannels", () => { expect(url.pathname).toContain("/channels"); expect(url.searchParams.get("iata")).toBe("YYZ"); expect(url.searchParams.has("iatas")).toBe(false); - expect(channels).toEqual([channel]); + expect(channels).toEqual(page); }); it("keeps the comma-joined iatas param for multi-IATA regions", async () => { diff --git a/tests/features/channels/ChannelList.test.tsx b/tests/features/channels/ChannelList.test.tsx new file mode 100644 index 00000000..30ea605e --- /dev/null +++ b/tests/features/channels/ChannelList.test.tsx @@ -0,0 +1,176 @@ +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ChannelList } from "../../../src/features/channels/ChannelList"; +import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; +import type { CursorPage } from "../../../src/types/api"; +import type { WsManager } from "../../../src/api/ws-manager"; + +let region = { iatas: ["YYZ"], regionKey: "YYZ" }; +let onMessage: (data: ChannelMessage) => void; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/hooks/useWsHandlers", () => ({ + useWsChannelMessageHandler: (_manager: unknown, handler: typeof onMessage) => { onMessage = handler; }, +})); +vi.mock("../../../src/features/channels/MessagePanel", () => ({ + MessagePanel: ({ channel }: { channel: ChannelSummary | null }) =>
{channel?.id ?? "none"}
, +})); + +const channel = (id: number, name = `Channel ${id}`): ChannelSummary => ({ + id, name, channelHash: id.toString(16).padStart(2, "0"), lastSeen: 10000 - id, isHashtag: false, keyKnown: true, +}); +const page = (items: ChannelSummary[], nextCursor: number | null = null): CursorPage => ({ items, nextCursor, hasMore: nextCursor !== null }); +const response = (data: CursorPage) => new Response(JSON.stringify(data), { status: 200 }); +const first = () => page(Array.from({ length: 50 }, (_, i) => channel(i + 1)), 5000); +const fetchMock = vi.fn(); + +function show(client = new QueryClient({ defaultOptions: { queries: { retry: false } } })) { + const app = () => {}} />; + return { ...render(app()), client, app }; +} +function lastURL() { return new URL(String(fetchMock.mock.calls.at(-1)![0])); } + +beforeEach(() => { + region = { iatas: ["YYZ"], regionKey: "YYZ" }; + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("channel directory paging", () => { + it("prefers the precise server cursor and preserves it through live updates", async () => { + const precise = "v1:1700000000000123:50"; + const firstPage = { ...first(), nextPageCursor: precise }; + fetchMock.mockResolvedValueOnce(response(firstPage)) + .mockResolvedValueOnce(response(page([channel(51, "Precise result")]))); + show(); + await screen.findByText("#Channel 1"); + act(() => onMessage({ id: 1, packetHash: "fixture", channelHash: "01", senderName: "fixture", content: "fixture", sentAt: 20000 })); + fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); + await screen.findByText("#Precise result"); + expect(lastURL().searchParams.get("pageCursor")).toBe(precise); + expect(lastURL().searchParams.has("cursor")).toBe(false); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("can retry an initial failure even without a page cursor", async () => { + fetchMock.mockRejectedValueOnce(new Error("temporary failure")) + .mockResolvedValueOnce(response(page([]))); + const { client } = show(); + await waitFor(() => expect(client.getQueryState(["channels", "YYZ"])?.status).toBe("error")); + fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(lastURL().searchParams.has("cursor")).toBe(false); + expect(screen.queryByRole("button", { name: "Load more channels" })).not.toBeInTheDocument(); + }); + + it("finds older matches one page at a time and keeps the selected channel when filtered out", async () => { + fetchMock.mockResolvedValueOnce(response(first())) + .mockResolvedValueOnce(response(page([channel(51, "Older match")]))); + show(); + await screen.findByText("#Channel 50"); + expect(fetchMock).toHaveBeenCalledTimes(1); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "older match" } }); + await screen.findByText("No matching channels loaded."); + fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); + fireEvent.click(await screen.findByRole("button", { name: /#Older match/ })); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("51"); + expect(lastURL().searchParams.get("cursor")).toBe("5000"); + expect(lastURL().searchParams.get("iata")).toBe("YYZ"); + expect(lastURL().searchParams.get("limit")).toBe("50"); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(screen.queryByRole("button", { name: "Load more channels" })).not.toBeInTheDocument(); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "absent" } }); + await screen.findByText("No matching channels loaded."); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("51"); + }); + + it("gates a pending page, offers retry after failure and trusts a full final page", async () => { + let rejectPage!: (error: Error) => void; + fetchMock.mockResolvedValueOnce(response(first())) + .mockImplementationOnce(() => new Promise((_resolve, reject) => { rejectPage = reject; })) + .mockResolvedValueOnce(response(page(Array.from({ length: 50 }, (_, i) => channel(i + 51))))); + show(); + fireEvent.click(await screen.findByRole("button", { name: "Load more channels" })); + const pending = await screen.findByRole("button", { name: "Loading channels..." }); + expect(pending).toBeDisabled(); + fireEvent.click(pending); + expect(fetchMock).toHaveBeenCalledTimes(2); + await act(async () => rejectPage(new Error("temporary failure"))); + fireEvent.click(await screen.findByRole("button", { name: "Retry loading channels" })); + await screen.findByText("#Channel 100"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(lastURL().searchParams.get("cursor")).toBe("5000"); + expect(screen.queryByRole("button", { name: "Load more channels" })).not.toBeInTheDocument(); + }); + + it("deduplicates IDs, preserves hash collisions and resets only the first page for an unseen live channel", async () => { + const older = channel(51, "Older selected"); + fetchMock.mockResolvedValueOnce(response(first())) + .mockResolvedValueOnce(response(page([channel(1), { ...channel(52), channelHash: "01" }, older], 4000))) + .mockResolvedValueOnce(response(first())); + const { client } = show(); + fireEvent.click(await screen.findByRole("button", { name: "Load more channels" })); + fireEvent.click(await screen.findByRole("button", { name: /#Older selected/ })); + expect(screen.getAllByText("#Channel 1")).toHaveLength(1); + expect(screen.getByText("#Channel 52")).toBeInTheDocument(); + const event = { id: 1, packetHash: "fixture", channelHash: older.channelHash, senderName: "fixture", content: "fixture", sentAt: 20000 }; + act(() => onMessage(event)); + const cached = client.getQueryData<{ pages: CursorPage[]; pageParams: unknown[] }>(["channels", "YYZ"])!; + expect(cached.pages[1]!.items.find(ch => ch.id === 51)?.lastSeen).toBe(20000); + expect(cached.pages[1]!.nextCursor).toBe(4000); + expect(cached.pageParams).toEqual([undefined, 5000]); + expect(fetchMock).toHaveBeenCalledTimes(2); + act(() => onMessage({ ...event, channelHash: "ff" })); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(client.getQueryData<{ pages: unknown[] }>(["channels", "YYZ"])?.pages).toHaveLength(1)); + expect(lastURL().searchParams.has("cursor")).toBe(false); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("51"); + }); + + it("coalesces unknown live channels during paging into one first-page refresh", async () => { + let resolvePage!: (response: Response) => void; + fetchMock.mockResolvedValueOnce(response(first())) + .mockImplementationOnce(() => new Promise(resolve => { resolvePage = resolve; })) + .mockResolvedValueOnce(response(page([channel(99, "New live channel")]))); + show(); + fireEvent.click(await screen.findByText("#Channel 1")); + fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); + await screen.findByRole("button", { name: "Loading channels..." }); + const event = { id: 1, packetHash: "fixture", channelHash: "ff", senderName: "fixture", content: "fixture", sentAt: 20000 }; + act(() => { onMessage(event); onMessage({ ...event, channelHash: "fe" }); }); + expect(fetchMock).toHaveBeenCalledTimes(2); + await act(async () => resolvePage(response(page([channel(51)], 4000)))); + await screen.findByText("#New live channel"); + expect(fetchMock).toHaveBeenCalledTimes(3); + expect(lastURL().searchParams.has("cursor")).toBe(false); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("1"); + }); + + it("keeps the open channel when the page cap evicts it, then clears selection and filters on region change", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + client.setQueryData(["channels", "YYZ"], { + pages: Array.from({ length: 20 }, (_, i) => page([channel(i + 1)], 9000 - i)), + pageParams: Array.from({ length: 20 }, (_, i) => i === 0 ? undefined : 9001 - i), + }); + fetchMock.mockResolvedValueOnce(response(page([channel(21)], 8000))) + .mockResolvedValueOnce(response(page([channel(101, "Other region")]))); + const view = show(client); + fireEvent.click(await screen.findByText("#Channel 1")); + fireEvent.click(screen.getByRole("button", { name: "Load more channels" })); + await screen.findByText("#Channel 21"); + expect(client.getQueryData<{ pages: unknown[] }>(["channels", "YYZ"])?.pages).toHaveLength(20); + expect(screen.queryByText("#Channel 1")).not.toBeInTheDocument(); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("1"); + fireEvent.change(screen.getByPlaceholderText("Search by name..."), { target: { value: "absent" } }); + await screen.findByText("No matching channels loaded."); + region = { iatas: ["YOW"], regionKey: "YOW" }; + view.rerender(view.app()); + await screen.findByText("#Other region"); + expect(screen.getByTestId("selected-channel")).toHaveTextContent("none"); + expect(screen.getByPlaceholderText("Search by name...")).toHaveValue(""); + expect(lastURL().searchParams.get("iata")).toBe("YOW"); + expect(lastURL().searchParams.has("cursor")).toBe(false); + }); +}); From 4304153f8b7ed65ef9ce1d2212e40e8851ace696 Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 10 Sep 2026 09:26:13 -0400 Subject: [PATCH 096/163] feat(packets): search latest paths by complete hop hashes (#39) --- src/components/FilterBar.tsx | 2 +- src/features/packets/PacketList.tsx | 14 +++- src/features/packets/path-search.ts | 36 +++++++++ src/features/packets/usePacketFilters.ts | 9 ++- tests/components/FilterBar.mobile.test.tsx | 10 +++ tests/features/packets/PacketList.test.tsx | 17 ++++ .../features/packets/usePacketFilters.test.ts | 79 ++++++++++++++++++- tests/features/packets/usePackets.test.tsx | 12 ++- 8 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 src/features/packets/path-search.ts diff --git a/src/components/FilterBar.tsx b/src/components/FilterBar.tsx index a3920fda..fb5b172f 100644 --- a/src/components/FilterBar.tsx +++ b/src/components/FilterBar.tsx @@ -12,7 +12,7 @@ interface FilterOption { const PACKET_SEARCH_FIELDS: SearchFieldOption[] = [ { value: "hash", label: "Hash" }, - { value: "path", label: "Path", disabled: true }, + { value: "path", label: "Latest path" }, { value: "payload", label: "Payload", disabled: true }, ]; diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index d57d4803..6a85a762 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -4,6 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { usePackets } from "./usePackets"; import { usePacketDetail } from "./usePacketDetail"; import { usePacketFilters, matchesFilters, toServerFilter } from "./usePacketFilters"; +import { parsePathSearch } from "./path-search"; import { useScopes } from "../../hooks/useScopes"; import { useRegion } from "../../hooks/useRegion"; import { useWsPacketHandler, useWsLaggedHandler } from "../../hooks/useWsHandlers"; @@ -44,6 +45,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati const { filters, setFilter, setSearch, setSearchField, clearFilters } = usePacketFilters(); // single-value selections go to the server so scrolling pages through matching history const serverFilter = useMemo(() => toServerFilter(filters), [filters]); + const pathSearch = useMemo(() => parsePathSearch(filters.search), [filters.search]); const scopeNames = useScopes(); const scopeOptions = useMemo(() => scopeNames.map((s) => ({ value: s, label: s })), [scopeNames]); const { regionKey } = useRegion(); @@ -73,8 +75,8 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati } = usePackets(!isAtTop, serverFilter); const packets = useMemo( - () => allPackets.filter((p) => matchesFilters(p, filters, observersByHash)), - [allPackets, filters, observersByHash], + () => allPackets.filter((p) => matchesFilters(p, filters, observersByHash, pathSearch)), + [allPackets, filters, observersByHash, pathSearch], ); // ?hash is the selected packet — it expands the row inline. The analyzer is a separate state (?analyze=1). @@ -168,6 +170,14 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati onClear={clearFilters} /> + {filters.searchField === "path" && ( +

+ {pathSearch === null + ? "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4)." + : "Searches the latest relay path in loaded packets. Use whole hop hashes, e.g. 7f a4 (spaces, commas or →). Trace packets are excluded."} +

+ )} + {laggedCount > 0 && (
{laggedCount} packet{laggedCount === 1 ? "" : "s"} dropped — data may be incomplete diff --git a/src/features/packets/path-search.ts b/src/features/packets/path-search.ts new file mode 100644 index 00000000..24079119 --- /dev/null +++ b/src/features/packets/path-search.ts @@ -0,0 +1,36 @@ +import type { PacketSummary } from "../../types/api"; +import { PayloadType } from "../../types/enums"; + +export interface PathSearch { + hashSize: number; + bytes: string; +} + +// Parse once per search change; complete hop hashes make the width unambiguous. +export function parsePathSearch(query: string): PathSearch | null { + const text = query.trim().toLowerCase(); + if (!text) return { hashSize: 0, bytes: "" }; + if (text.length > 1024) return null; + const hops = text.split(/[\s,→]+/); + const width = hops[0]!.length; + if (!hops.every(hop => hop.length === width && /^(?:[0-9a-f]{2}){1,4}$/.test(hop))) return null; + return { hashSize: width / 2, bytes: hops.join("") }; +} + +export function matchesPathSearch(packet: PacketSummary, search: PathSearch | null): boolean { + if (!search) return false; + if (!search.bytes) return true; + const observer = packet.latestObserver; + const length = observer?.pathLength; + const bytes = observer?.pathBytes; + // TRACE summaries carry SNR samples here, not the relay hashes from trace detail. + if (packet.payloadType === PayloadType.TRACE || !length || !bytes || + length.hashSize !== search.hashSize || !Number.isInteger(length.hopCount) || length.hopCount <= 0 || + bytes.length !== length.hopCount * length.hashSize * 2 || search.bytes.length > bytes.length || + !/^[0-9a-f]+$/i.test(bytes)) return false; + const path = bytes.toLowerCase(); + for (let offset = 0; offset <= path.length - search.bytes.length; offset += search.hashSize * 2) { + if (path.startsWith(search.bytes, offset)) return true; + } + return false; +} diff --git a/src/features/packets/usePacketFilters.ts b/src/features/packets/usePacketFilters.ts index e1bb284c..9cc46c7c 100644 --- a/src/features/packets/usePacketFilters.ts +++ b/src/features/packets/usePacketFilters.ts @@ -3,6 +3,7 @@ import { useMemo, useCallback } from "react"; import type { PacketFilterState, PacketServerFilter, SearchField } from "./types"; import type { PacketSummary } from "../../types/api"; import type { PayloadTypeValue, RouteTypeValue } from "../../types/enums"; +import { matchesPathSearch, parsePathSearch, type PathSearch } from "./path-search"; // filter state synced to URL search params @@ -16,9 +17,8 @@ function parseStringArray(value: string | null): string[] { return value.split(",").filter(Boolean); } -// "path"/"payload" show as disabled placeholders in the filter bar — matchesFilters only -// implements hash, so don't accept them from the URL either or the search silently does nothing -const IMPLEMENTED_SEARCH_FIELDS = new Set(["hash"]); +// Keep unimplemented search modes out of URLs as well as the dropdown. +const IMPLEMENTED_SEARCH_FIELDS = new Set(["hash", "path"]); function parseSearchField(value: string | null): SearchField { if (value && IMPLEMENTED_SEARCH_FIELDS.has(value as SearchField)) return value as SearchField; @@ -132,6 +132,7 @@ export function matchesFilters( packet: PacketSummary, filters: PacketFilterState, observersByHash?: ReadonlyMap>, + pathSearch?: PathSearch | null, ): boolean { if (filters.payloadTypes.length > 0 && !filters.payloadTypes.includes(packet.payloadType as PayloadTypeValue)) { return false; @@ -155,5 +156,7 @@ export function matchesFilters( return false; } } + if (filters.searchField === "path" && !matchesPathSearch(packet, + pathSearch === undefined ? parsePathSearch(filters.search) : pathSearch)) return false; return true; } diff --git a/tests/components/FilterBar.mobile.test.tsx b/tests/components/FilterBar.mobile.test.tsx index 0157e826..59190335 100644 --- a/tests/components/FilterBar.mobile.test.tsx +++ b/tests/components/FilterBar.mobile.test.tsx @@ -39,6 +39,16 @@ const baseProps = { }; describe("FilterBar (mobile)", () => { + it.each([true, false])("offers latest-path search on mobile=%s", (mobile) => { + setMobile(mobile); + const onSearchFieldChange = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: /Hash/ })); + fireEvent.click(screen.getByRole("button", { name: "Latest path", exact: true })); + expect(onSearchFieldChange).toHaveBeenCalledWith("path"); + expect(screen.queryByRole("button", { name: "Latest path", exact: true })).not.toBeInTheDocument(); + }); + it("hides the inline dropdowns behind a Filters button until opened", () => { setMobile(true); render(); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 8366c68d..3fff03b7 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -144,6 +144,23 @@ describe("PacketList loading feedback", () => { usePackets.mockImplementation(basePackets); }); + it("applies latest-path search to the list and explains its scope", () => { + const matching = { ...packet("matched"), payloadType: 4, latestObserver: { + id: "o1", iata: "YOW", pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, pathBytes: "7fa4", + } }; + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [matching, packet("other")] })); + renderList("/?sf=path&q=7f%20a4&types=4"); + expect(screen.getByText("matched")).toBeInTheDocument(); + expect(screen.queryByText("other")).not.toBeInTheDocument(); + expect(screen.getByText(/latest relay path in loaded packets/)).toBeInTheDocument(); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] }); + }); + + it("explains an invalid path query instead of silently ignoring it", () => { + renderList("/?sf=path&q=not-hex"); + expect(screen.getByRole("alert")).toHaveTextContent("whole hop hashes of the same length"); + }); + it("shows skeletons instead of the list plus a loading pill during an empty initial load", () => { usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true })); renderList(); diff --git a/tests/features/packets/usePacketFilters.test.ts b/tests/features/packets/usePacketFilters.test.ts index 88b19a10..d6de6645 100644 --- a/tests/features/packets/usePacketFilters.test.ts +++ b/tests/features/packets/usePacketFilters.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { renderHook } from "@testing-library/react"; +import { act, renderHook } from "@testing-library/react"; import { createElement, type ReactNode } from "react"; import { MemoryRouter } from "react-router-dom"; import { matchesFilters, toServerFilter, usePacketFilters } from "../../../src/features/packets/usePacketFilters"; @@ -97,10 +97,83 @@ describe("usePacketFilters — sf param", () => { }); it("falls back to hash for unimplemented sf values", () => { - // path/payload search isn't implemented — accepting them would silently match everything - for (const sf of ["path", "payload", "bogus"]) { + for (const sf of ["payload", "bogus"]) { const { result } = renderHook(() => usePacketFilters(), { wrapper: routerAt(`/?sf=${sf}&q=ab`) }); expect(result.current.filters.searchField).toBe("hash"); } }); + + it("restores path search from a URL and can switch or clear it", () => { + const { result } = renderHook(() => usePacketFilters(), { wrapper: routerAt("/?sf=path&q=7f%20a4&types=4") }); + expect(result.current.filters.searchField).toBe("path"); + expect(result.current.filters.search).toBe("7f a4"); + expect(result.current.filters.payloadTypes).toEqual([4]); + act(() => result.current.setSearchField("hash")); + expect(result.current.filters.searchField).toBe("hash"); + act(() => result.current.setSearchField("path")); + expect(result.current.filters.searchField).toBe("path"); + act(() => result.current.clearFilters()); + expect(result.current.filters).toEqual(EMPTY_FILTERS); + }); +}); + +describe("matchesFilters — latest path", () => { + const packet = (hashSize = 1, pathBytes = "007fa499"): PacketSummary => pkt({ + latestObserver: { id: "o1", iata: "YOW", pathLength: { raw: "04", hashSize, hopCount: pathBytes.length / (hashSize * 2) }, pathBytes }, + }); + const search = (value: string) => ({ ...EMPTY_FILTERS, searchField: "path" as const, search: value }); + + it.each([1, 2, 3, 4])("matches whole hashes and sequences for %i-byte hops", (width) => { + const a = "11".repeat(width), b = "22".repeat(width), c = "33".repeat(width); + const p = packet(width, a + b + c); + expect(matchesFilters(p, search(b))).toBe(true); + expect(matchesFilters(p, search(`${a} ${b}`))).toBe(true); + expect(matchesFilters(p, search(`${b}, ${c}`))).toBe(true); + expect(matchesFilters(p, search(`${c} ${b}`))).toBe(false); + expect(matchesFilters(p, search("44".repeat(width)))).toBe(false); + }); + + it("normalizes case and separators while keeping hop boundaries", () => { + expect(matchesFilters(packet(), search(" 7F,\tA4 "))).toBe(true); + expect(matchesFilters(packet(), search("7F→A4"))).toBe(true); + expect(matchesFilters(packet(), search("7fa4"))).toBe(false); // a two-byte hop, not two one-byte hops + expect(matchesFilters(packet(2, "aabbccdd"), search("bbcc"))).toBe(false); // crosses a hop boundary + expect(matchesFilters(packet(2, "aabbccdd"), search("aa"))).toBe(false); // partial hop + }); + + it.each(["a", "abc", "abcd0", "aabbccddeeff", "zz", "7f aabb", "7f > a4", "7f,a4,", "aa".repeat(600)])("rejects invalid query %s", (query) => { + expect(matchesFilters(packet(), search(query))).toBe(false); + }); + + it("keeps an empty path query neutral, including packets without path metadata", () => { + expect(matchesFilters(pkt({}), search(""))).toBe(true); + expect(matchesFilters(pkt({ payloadType: 9 }), search(" "))).toBe(true); + }); + + it("rejects absent, invalid or inconsistent path metadata", () => { + const p = packet(); + const observer = p.latestObserver!; + for (const latestObserver of [undefined, { ...observer, pathLength: undefined }, { ...observer, pathBytes: undefined }, + { ...observer, pathBytes: "007fz499" }, { ...observer, pathBytes: "007fa4" }, + { ...observer, pathLength: { raw: "00", hashSize: 0, hopCount: 4 } }, + { ...observer, pathLength: { raw: "00", hashSize: 1, hopCount: 4.5 } }, + { ...observer, pathLength: { raw: "00", hashSize: 1, hopCount: 0 } }]) { + expect(matchesFilters({ ...p, latestObserver }, search("7f"))).toBe(false); + } + }); + + it("excludes trace SNR bytes and does not search endpoint identities", () => { + expect(matchesFilters({ ...packet(), payloadType: 9 }, search("7f"))).toBe(false); + const p = packet(); + p.latestObserver!.resolvedSource = { confidence: "high", nodes: [{ id: "n1", name: "aa", publicKey: "aa" }] }; + expect(matchesFilters(p, search("aa"))).toBe(false); + }); + + it("combines path matching with existing filters without adding a server search", () => { + const p = { ...packet(), scope: "#test" }; + const filters = { ...search("7f"), scopes: ["#test"], observers: ["o1"], payloadTypes: [2] as PayloadTypeValue[] }; + expect(matchesFilters(p, filters)).toBe(true); + expect(matchesFilters(p, { ...filters, scopes: ["#other"] })).toBe(false); + expect(toServerFilter(search("7f"))).toBeNull(); + }); }); diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index bb07f8ce..ba98a8f4 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -3,6 +3,8 @@ import { renderHook, waitFor, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { usePackets } from "../../../src/features/packets/usePackets"; +import { matchesFilters } from "../../../src/features/packets/usePacketFilters"; +import { EMPTY_FILTERS } from "../../../src/features/packets/types"; import type { PacketServerFilter } from "../../../src/features/packets/types"; import type { PacketSummary } from "../../../src/types/api"; import type { WsPacketObservation } from "../../../src/types/ws"; @@ -258,8 +260,13 @@ describe("usePackets path and endpoint fields", () => { {children} ); - it("carries path and endpoint fields from the WS observation into latestObserver", () => { + it("carries path and endpoint fields from the WS observation into latestObserver", async () => { + const history: PacketSummary = { ...packet("history"), latestObserver: { + id: "obs-rest", iata: "YVR", pathLength: { raw: "42", hashSize: 1, hopCount: 2 }, pathBytes: "7fa4", + } }; + getPackets.mockResolvedValue({ items: [history], nextCursor: null }); const { result } = renderHook(() => usePackets(false, undefined), { wrapper }); + await waitFor(() => expect(result.current.allPackets).toHaveLength(1)); act(() => { result.current.handlePacketObservation({ @@ -294,6 +301,9 @@ describe("usePackets path and endpoint fields", () => { expect(obs?.pathBytes).toBe("7fa4"); expect(obs?.resolvedSource?.nodes[0]!.name).toBe("Salish"); expect(obs?.resolvedDestination).toBeUndefined(); + const filters = { ...EMPTY_FILTERS, searchField: "path" as const, search: "7F A4" }; + expect(result.current.allPackets.filter(p => matchesFilters(p, filters)).map(p => p.packetHash)).toEqual(["AA11", "history"]); + expect(result.current.allPackets.filter(p => matchesFilters(p, { ...filters, search: "ff" }))).toEqual([]); }); }); From e43de48f0be5ab755c011a8f1c95d779df5ad274 Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 10 Sep 2026 09:30:01 -0400 Subject: [PATCH 097/163] feat(packets): surface selections outside loaded results (#41) A ?hash selection outside the loaded or filtered results now shows an Open analyzer notice with loading, error/retry and dismiss states, using the existing detail query. --- DEEPLINK-HANDOFF.md | 4 ++ src/features/packets/PacketList.tsx | 24 ++++++- tests/App.packetUrlContract.test.tsx | 34 +++++++++- tests/features/packets/PacketList.test.tsx | 75 ++++++++++++++++++++-- 4 files changed, 130 insertions(+), 7 deletions(-) diff --git a/DEEPLINK-HANDOFF.md b/DEEPLINK-HANDOFF.md index 3de21e26..e9b304fb 100644 --- a/DEEPLINK-HANDOFF.md +++ b/DEEPLINK-HANDOFF.md @@ -1,5 +1,9 @@ # Packet deep link investigation — 2026-08-07 +## Optional hardening added + +A `hash` selection outside the loaded or filtered results now shows an explicit **Open analyzer** action, using the existing detail query. Loading, request errors, retry and dismissal are visible. Loaded selections continue expanding inline, and `analyze=1` remains the flag that opens the drawer. Filters and region selection are preserved. The historical investigation below explains the original behavior. + ## Outcome: no bug in beacon-web A packet deep link that "doesn't pop up" was traced to a hand-built URL missing the diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 6a85a762..2321faa8 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -12,6 +12,7 @@ import { PacketVirtualList } from "./PacketVirtualList"; import { FilterBar } from "../../components/FilterBar"; import { LoadingPill } from "../../components/LoadingPill"; import { SkeletonRows } from "../../components/SkeletonRows"; +import { CloseButton } from "../../components/CloseButton"; import { PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES } from "../../types/enums"; import type { WsManager } from "../../api/ws-manager"; import type { PacketDetail } from "../../types/api"; @@ -92,7 +93,8 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati }, [expandedHash, setSearchParams]); // Shared with the expanded row's own usePacketDetail, so reading it here costs no extra request. - const { data: expandedDetail } = usePacketDetail(expandedHash); + const { data: expandedDetail, isError: selectedError, error: detailError, refetch: retryDetail } = usePacketDetail(expandedHash); + const detailStatus = detailError && "status" in detailError ? detailError.status : undefined; const handleOpenAnalyzer = useCallback(() => { if (expandedHash) onAnalyze(expandedHash); @@ -178,6 +180,26 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati

)} + {/* A hash-only link still selects a row; offer the existing analyzer when there is no row. */} + {expandedHash && !isLoading && searchParams.get("analyze") !== "1" && !packets.some(p => p.packetHash === expandedHash) && ( +
+
+ {selectedError ? ( + <> +

{detailStatus === 400 ? "Invalid packet hash." : detailStatus === 404 ? "Packet not found." : "Failed to load selected packet."}

+ + + ) : expandedDetail ? ( + <> +

Selected packet is outside the loaded results.

+ + + ) :

Loading selected packet…

} +
+ handleToggleExpand(expandedHash)} /> +
+ )} + {laggedCount > 0 && (
{laggedCount} packet{laggedCount === 1 ? "" : "s"} dropped — data may be incomplete diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index c77d70e7..749524dd 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act, within } from "@testing-library/react"; import { App } from "../src/App"; import type { PacketSummary, PacketDetail } from "../src/types/api"; @@ -70,7 +70,7 @@ vi.mock("../src/features/packets/usePackets", () => ({ vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ - data: hash === "AA11" ? detail : undefined, + data: hash === "AA11" ? detail : hash === "BB22" ? { ...detail, packetHash: "BB22" } : undefined, isLoading: false, isError: false, refetch: () => {}, @@ -136,6 +136,36 @@ afterEach(() => { }); describe("Packets deep links", () => { + it.each([false, true])("opens an unloaded selection explicitly and preserves region and filters (mobile=%s)", async (mobile) => { + setMobile(mobile); + window.history.pushState({}, "", "/?tab=Packets&hash=BB22&iata=YOW&q=missing"); + render(); + const notice = await screen.findByRole("region", { name: "Selected packet" }); + expect(screen.queryByTestId("packet-analyzer-drawer")).not.toBeInTheDocument(); + fireEvent.click(within(notice).getByRole("button", { name: "Open analyzer" })); + expect(screen.getByTestId("packet-analyzer-drawer")).toHaveTextContent("BB22"); + expect(screen.queryByRole("region", { name: "Selected packet" })).not.toBeInTheDocument(); + const params = new URLSearchParams(window.location.search); + expect(params.get("analyze")).toBe("1"); + expect(params.get("hash")).toBe("BB22"); + expect(params.get("iata")).toBe("YOW"); + expect(params.get("q")).toBe("missing"); + fireEvent.click(screen.getByRole("button", { name: "Close analyzer" })); + expect(screen.getByRole("region", { name: "Selected packet" })).toBeInTheDocument(); + }); + + it("updates the selection notice on URL navigation", async () => { + window.history.pushState({}, "", "/?tab=Packets&hash=BB22"); + render(); + expect(await screen.findByRole("region", { name: "Selected packet" })).toBeInTheDocument(); + act(() => { + window.history.pushState({}, "", "/?tab=Packets&hash=AA11"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + expect(screen.queryByRole("region", { name: "Selected packet" })).not.toBeInTheDocument(); + expect(screen.getByTestId("packet-expansion")).toBeInTheDocument(); + }); + it("restores the expanded row and the drawer from ?hash&analyze=1", async () => { window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1"); render(); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index 3fff03b7..e4244ae9 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { PacketList } from "../../../src/features/packets/PacketList"; @@ -29,7 +29,8 @@ vi.mock("../../../src/features/packets/usePackets", () => ({ usePackets: (...args: unknown[]) => usePackets(...(args as [])), })); -const usePacketDetail = vi.fn(() => ({ data: undefined as PacketDetail | undefined })); +const baseDetail = () => ({ data: undefined as PacketDetail | undefined, isLoading: false, isError: false, error: null as Error | null, refetch: vi.fn() }); +const usePacketDetail = vi.fn(baseDetail); vi.mock("../../../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => usePacketDetail(hash as never), })); @@ -196,7 +197,7 @@ describe("PacketList loading feedback", () => { describe("PacketList expanded row", () => { afterEach(() => { usePackets.mockImplementation(basePackets); - usePacketDetail.mockReturnValue({ data: undefined }); + usePacketDetail.mockReturnValue(baseDetail()); }); it("expands a row from ?hash without opening the analyzer", () => { @@ -229,7 +230,7 @@ describe("PacketList expanded row", () => { it("hands the loaded detail to onViewPath", () => { const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); - usePacketDetail.mockReturnValue({ data: detail }); + usePacketDetail.mockReturnValue({ ...baseDetail(), data: detail }); const { onViewPath } = renderList("/?tab=Packets&hash=AA11"); @@ -288,3 +289,69 @@ describe("PacketList live observation invalidation", () => { expect(invalidate).not.toHaveBeenCalled(); }); }); + +describe("PacketList selected packet outside the loaded results", () => { + afterEach(() => { + usePackets.mockImplementation(basePackets); + usePacketDetail.mockReturnValue(baseDetail()); + }); + + it("offers the existing analyzer without scanning history for an unloaded packet", () => { + const fetchNextPage = vi.fn(); + usePackets.mockImplementation(() => ({ ...basePackets(), hasNextPage: true, fetchNextPage })); + usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail }); + const { onAnalyze } = renderList("/?hash=AA11"); + const notice = screen.getByRole("region", { name: "Selected packet" }); + expect(notice).toHaveTextContent("outside the loaded results"); + fireEvent.click(within(notice).getByRole("button", { name: "Open analyzer" })); + expect(onAnalyze).toHaveBeenCalledWith("AA11"); + expect(fetchNextPage).not.toHaveBeenCalled(); + }); + + it("offers the action when current filters hide a loaded selection", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); + usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail }); + renderList("/?hash=AA11&q=other"); + expect(screen.getByRole("region", { name: "Selected packet" })).toHaveTextContent("outside the loaded results"); + fireEvent.click(screen.getByRole("button", { name: "Clear", exact: true })); + expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull(); + expect(screen.getByRole("button", { name: "AA11", exact: true })).toHaveAttribute("aria-expanded", "true"); + }); + + it.each(["/?hash=AA11", "/?hash=AA11&analyze=1", "/"])("does not duplicate a loaded row or an analyzer (%s)", (url) => { + usePackets.mockImplementation(() => ({ ...basePackets(), allPackets: [packet("AA11")] })); + renderList(url); + expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull(); + }); + + it("waits for the initial list before declaring the selection outside it", () => { + usePackets.mockImplementation(() => ({ ...basePackets(), isLoading: true })); + renderList("/?hash=AA11"); + expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull(); + }); + + it("shows the selected detail loading state", () => { + usePacketDetail.mockReturnValue({ ...baseDetail(), isLoading: true }); + renderList("/?hash=AA11"); + expect(screen.getByRole("region", { name: "Selected packet" })).toHaveTextContent("Loading selected packet"); + }); + + it.each([[400, "Invalid packet hash"], [404, "Packet not found"], [503, "Failed to load selected packet"]])("explains HTTP %i and lets the user retry", (status, message) => { + const refetch = vi.fn(); + usePacketDetail.mockReturnValue({ ...baseDetail(), isError: true, error: Object.assign(new Error("request failed"), { status }), refetch }); + renderList("/?hash=AA11"); + expect(screen.getByRole("alert")).toHaveTextContent(message); + fireEvent.click(screen.getByRole("button", { name: "Retry selected packet" })); + expect(refetch).toHaveBeenCalledTimes(1); + }); + + it("dismisses only the selection, leaving the current filters in place", () => { + usePacketDetail.mockReturnValue({ ...baseDetail(), data: { packetHash: "AA11" } as PacketDetail }); + renderList("/?hash=AA11&q=other&types=4"); + fireEvent.click(screen.getByRole("button", { name: "Dismiss selected packet" })); + expect(screen.queryByRole("region", { name: "Selected packet" })).toBeNull(); + expect(screen.getByTestId("expanded")).toHaveTextContent("null"); + expect(screen.getByPlaceholderText("Search by hash...")).toHaveValue("other"); + expect(usePackets).toHaveBeenLastCalledWith(false, { payloadTypes: [4] }); + }); +}); From 7549bacd217952a67421970e57c758a047a45139 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 18 Sep 2026 06:07:54 -0400 Subject: [PATCH 098/163] fix(region): avoid focus zoom on small filter text (#45) --- src/components/AppShell.tsx | 3 ++- tests/browser/region-input-zoom.js | 13 +++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 tests/browser/region-input-zoom.js diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index bd70ba57..0d824d02 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -199,6 +199,7 @@ function RegionSelectorPanel() { return ( <>
+ {/* Keep focused text at 16px so iOS Safari does not zoom the page. */}
diff --git a/tests/browser/region-input-zoom.js b/tests/browser/region-input-zoom.js new file mode 100644 index 00000000..f7dce0dc --- /dev/null +++ b/tests/browser/region-input-zoom.js @@ -0,0 +1,13 @@ +// Open REGION, then run in a real browser at phone, tablet and desktop widths. +// jsdom does not load Tailwind's generated CSS; inspect the rendered input instead. +// Also check on iOS Safari: opening, filtering and closing must leave zoom unchanged. +(function checkRegionInputZoom() { + const input = document.querySelector('header input[placeholder="Filter IATA or name…"]'); + if (!input) throw new Error("Open the region picker first"); + const fontSize = Number.parseFloat(getComputedStyle(input).fontSize); + if (fontSize < 16 || !Number.isFinite(fontSize)) { + throw new Error(`Region filter is ${fontSize}px; small focused inputs can trigger Safari zoom`); + } + if (document.activeElement !== input) throw new Error("Region filter did not receive focus"); + return { viewport: document.documentElement.clientWidth, fontSize, focused: true }; +})(); From 34bd0948bc2b94f434462f8301a2eb0808b771bb Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 18 Sep 2026 06:09:59 -0400 Subject: [PATCH 099/163] feat(packets): display payload summaries in packet rows (#46) --- src/features/packets/PacketRow.tsx | 2 ++ src/features/packets/PacketTableHeader.tsx | 2 +- src/features/packets/PacketTableRow.tsx | 1 + src/features/packets/usePackets.ts | 1 + src/types/api.ts | 1 + src/types/ws.ts | 1 + .../packets/PacketTableHeader.test.tsx | 2 +- .../features/packets/PacketTableRow.test.tsx | 7 +++++ .../features/packets/packet-summary.test.tsx | 26 +++++++++++++++++++ tests/features/packets/usePackets.test.tsx | 14 ++++++++++ 10 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 tests/features/packets/packet-summary.test.tsx diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index 1c941b09..2fcba531 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -54,6 +54,8 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp
+ {packet.summary &&
{packet.summary}
} +
{packet.routeTypeName || "Unknown"} diff --git a/src/features/packets/PacketTableHeader.tsx b/src/features/packets/PacketTableHeader.tsx index 97ae3da6..e84f7ecb 100644 --- a/src/features/packets/PacketTableHeader.tsx +++ b/src/features/packets/PacketTableHeader.tsx @@ -14,7 +14,7 @@ export function PacketTableHeader() { Obs Hops Hash Size - Src → Dst + Summary / Src → Dst IATA Age
diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 95a430f2..20339037 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -58,6 +58,7 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa {pathLength?.hopCount ?? na} {pathLength?.hashSize ?? na} + {packet.summary && {packet.summary}} diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index ef453ee2..3f03b557 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -163,6 +163,7 @@ export function usePackets(frozen: boolean = false, serverFilter: PacketServerFi lastHeardAt: data.observation.heardAt, observationCount: data.packet.observationCount, scope: data.packet.scope, + summary: data.packet.summary, latestObserver: { id: data.observation.observerId, displayName: data.observation.observerName, diff --git a/src/types/api.ts b/src/types/api.ts index f50ac808..e8fbf134 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -35,6 +35,7 @@ export interface PacketSummary { observationCount: number; latestObserver?: LatestObserver; scope?: string; // matched transport scope name, e.g. "#bc" + summary?: string; // packet-derived display text, currently the advertised name } export interface ResolvedNode { diff --git a/src/types/ws.ts b/src/types/ws.ts index 7701ccd9..3b72345b 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -51,6 +51,7 @@ export interface WsPacketObservation { isFirstObservation: boolean; observationCount: number; scope?: string; // matched transport scope name; omitted when none matched + summary?: string; }; observation: { observerId: string; diff --git a/tests/features/packets/PacketTableHeader.test.tsx b/tests/features/packets/PacketTableHeader.test.tsx index de254c00..4eaa633c 100644 --- a/tests/features/packets/PacketTableHeader.test.tsx +++ b/tests/features/packets/PacketTableHeader.test.tsx @@ -6,7 +6,7 @@ import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid"; describe("PacketTableHeader", () => { it("declares every column heading", () => { render(); - for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Hash Size", "Src → Dst", "IATA", "Age"]) { + for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Hash Size", "Summary / Src → Dst", "IATA", "Age"]) { expect(screen.getByText(h)).toBeInTheDocument(); } }); diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 27435ae2..2e114856 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -23,6 +23,13 @@ const observer = ( }; describe("PacketTableRow", () => { + it("keeps endpoints visible alongside a payload summary", () => { + render( {}} />); + expect(screen.getByText("Packet summary")).toBeInTheDocument(); + expect(screen.getByText("Source node")).toBeInTheDocument(); + expect(screen.getByText("Destination node")).toBeInTheDocument(); + }); + it("exposes one button carrying the expansion state", () => { render( {}} />); const btn = screen.getByRole("button"); diff --git a/tests/features/packets/packet-summary.test.tsx b/tests/features/packets/packet-summary.test.tsx new file mode 100644 index 00000000..ca7129f2 --- /dev/null +++ b/tests/features/packets/packet-summary.test.tsx @@ -0,0 +1,26 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { PacketRow } from "../../../src/features/packets/PacketRow"; +import { PacketTableRow } from "../../../src/features/packets/PacketTableRow"; + +const packet = { + packetHash: "AA11BB22", payloadType: 4, payloadTypeName: "ADVERT", + routeType: 1, routeTypeName: "FLOOD", firstHeardAt: 1, lastHeardAt: 2, observationCount: 1, +}; + +describe.each([["mobile", PacketRow], ["desktop", PacketTableRow]] as const)("%s packet summary", (_name, Row) => { + it("shows the packet's advertised name and keeps row selection working", () => { + const toggle = vi.fn(); + render(); + const summary = screen.getByText("MD00-Repeater 📡"); + expect(summary).toHaveAttribute("title", "MD00-Repeater 📡"); + fireEvent.click(summary); + expect(toggle).toHaveBeenCalledTimes(1); + }); + + it("supports rows from an older server without summaries", () => { + render( {}} />); + expect(screen.getByText(/AA11/)).toBeInTheDocument(); + expect(screen.queryByText("MD00-Repeater 📡")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/packets/usePackets.test.tsx b/tests/features/packets/usePackets.test.tsx index ba98a8f4..b0198d1e 100644 --- a/tests/features/packets/usePackets.test.tsx +++ b/tests/features/packets/usePackets.test.tsx @@ -360,6 +360,20 @@ describe("usePackets live heard window", () => { const flushRaf = () => rafCallbacks.splice(0).forEach((cb) => cb(0)); + it("keeps summaries from history and passes summaries through live observations", async () => { + getPackets.mockResolvedValue({ items: [{ ...packet("history"), summary: "Historical advert" }], nextCursor: null }); + const { result } = renderHook(() => usePackets(), { wrapper }); + await waitFor(() => expect(result.current.allPackets.find((p) => p.packetHash === "history")?.summary).toBe("Historical advert")); + const event = observation("live"); + event.packet.summary = "Live advert 📡"; + act(() => { + result.current.handlePacketObservation(event); + flushRaf(); + }); + expect(result.current.allPackets.find((p) => p.packetHash === "live")?.summary).toBe("Live advert 📡"); + expect(result.current.allPackets.find((p) => p.packetHash === "history")?.summary).toBe("Historical advert"); + }); + // Each WS message carries only its own heardAt, so a second observation of the same packet used to // collapse the window to a single instant — the expanded row then read "spread 0.000s". it("widens the heard window across observations instead of collapsing it", async () => { From 2264b3be35ec92560db6417b98e624b2ac539893 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 18 Sep 2026 06:10:45 -0400 Subject: [PATCH 100/163] fix(layout): keep header controls inside narrow viewports (#44) --- src/components/AppShell.tsx | 9 ++++++--- tests/browser/header-layout.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 tests/browser/header-layout.js diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 0d824d02..3f4afb1f 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -114,6 +114,7 @@ function RegionSelector() { return ( ( }> + {() =>
option
} +
, + ); + fireEvent.click(screen.getByText("trigger")); + const panel = screen.getByTestId("dropdown-panel").parentElement!; + expect(panel.className).toContain("left-0"); + expect(panel.className).toContain("md:left-auto"); + expect(panel.className).toContain("md:right-0"); + }); +}); From 88e2c0f319abd28d1d851393f3bb085f7f61b6bf Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 19 Sep 2026 22:58:36 -0400 Subject: [PATCH 102/163] feat(stats): compare flood packets heard by two observers (#48) --- src/api/client.ts | 13 +- src/features/stats/CompareObserversTab.tsx | 163 ++++++++++++++++++ src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 5 +- src/features/stats/types.ts | 15 +- tests/api/observer-comparison.test.ts | 17 ++ .../stats/CompareObserversTab.test.tsx | 110 ++++++++++++ 7 files changed, 321 insertions(+), 6 deletions(-) create mode 100644 src/features/stats/CompareObserversTab.tsx create mode 100644 tests/api/observer-comparison.test.ts create mode 100644 tests/features/stats/CompareObserversTab.test.tsx diff --git a/src/api/client.ts b/src/api/client.ts index 236d43d7..011408b3 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,6 +6,7 @@ import type { ObserverSummary, Observer, AdvertObservation } from "../features/o import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; import type { StatsOverview, + ObserverComparison, ObservationPoint, PayloadBreakdownItem, TopNode, @@ -47,7 +48,7 @@ async function errorFrom(res: Response): Promise { return new ApiError(res.status, body.error?.code ?? "unknown", body.error?.message ?? res.statusText, retryAfterMs); } -async function request(path: string, params?: Record): Promise { +async function request(path: string, params?: Record, signal?: AbortSignal): Promise { const url = new URL(`${API_BASE}${path}`, window.location.origin); if (params) { for (const [key, value] of Object.entries(params)) { @@ -57,7 +58,7 @@ async function request(path: string, params?: Record(path: string, params?: Record { + return request("/stats/observer-comparison", { ...params, iatas: iatasParam(iatas) }, signal); +} + // The region filter travels as the comma-separated `iatas` param; undefined/empty means all regions. function iatasParam(iatas?: string[]): string | undefined { return iatas && iatas.length > 0 ? iatas.join(",") : undefined; diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx new file mode 100644 index 00000000..7605afbf --- /dev/null +++ b/src/features/stats/CompareObserversTab.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState, type FormEvent } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { Card } from "./cards"; + +type Selection = { observerA: string; observerB: string; since: number; until: number }; +const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +const fieldClass = "min-w-0 w-full rounded border border-border bg-bg-base px-2.5 py-2 text-base text-text-normal"; + +function validation(value: Selection): string | null { + if (!uuid.test(value.observerA) || !uuid.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { + return "Choose two different observers."; + } + if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { + return "Choose a valid start and a later end time."; + } + return null; +} + +function localTime(ms: number) { + const date = new Date(ms); + return new Date(ms - date.getTimezoneOffset() * 60_000).toISOString().slice(0, 23); +} + +function ObserverSelect({ label, value, onChange }: { label: string; value: string; onChange: (id: string) => void }) { + const { iatas, regionKey } = useRegion(); + const [search, setSearch] = useState(""); + const [name, setName] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setName(search.trim()), 250); + return () => clearTimeout(timer); + }, [search]); + const options = useQuery({ + queryKey: ["comparison-observers", regionKey, name], + queryFn: () => getObserversPage(iatas, { name: name || undefined, limit: 50 }), + staleTime: 30_000, + }); + const selected = useQuery({ + queryKey: ["observer", value], queryFn: () => getObserver(value), + enabled: uuid.test(value), staleTime: 30_000, retry: false, + }); + const rows = options.data?.items ?? []; + return ( +
+ {label} + setSearch(e.target.value)} className={fieldClass} /> + +

+ {options.isPending ? "Loading observers…" : options.isError ? "Observer search failed. Try again." : options.data?.hasMore ? "Showing 50 observers. Search to find others." : rows.length === 0 ? "No observers match this search." : "Search includes all matching observers in the selected region."} +

+ {options.isError && } +
+ ); +} + +function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onCompare: (value: Selection) => void }) { + const [a, setA] = useState(initial?.observerA ?? ""); + const [b, setB] = useState(initial?.observerB ?? ""); + const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000)); + const [until, setUntil] = useState(() => localTime(initial?.until ?? Math.floor(Date.now() / 60_000) * 60_000)); + const [error, setError] = useState(null); + function submit(e: FormEvent) { + e.preventDefault(); + const value = { observerA: a.toLowerCase(), observerB: b.toLowerCase(), since: new Date(since).getTime(), until: new Date(until).getTime() }; + const message = validation(value); + setError(message); + if (!message) onCompare(value); + } + return ( +
+
+ + + + +
+ {error &&

{error}

} + +
+ ); +} + +export function CompareObserversTab() { + const { iatas, regionKey } = useRegion(); + const [params, setParams] = useSearchParams(); + const keys = ["compareA", "compareB", "compareSince", "compareUntil"]; + const supplied = keys.some((key) => params.has(key)); + const parsed: Selection = { + observerA: params.get("compareA") ?? "", observerB: params.get("compareB") ?? "", + since: Number(params.get("compareSince")), until: Number(params.get("compareUntil")), + }; + const valid = keys.every((key) => params.getAll(key).length === 1 && params.get(key) !== "") && !validation(parsed); + const selection = valid ? parsed : null; + const result = useQuery({ + queryKey: ["observer-comparison", regionKey, selection], + queryFn: ({ signal }) => getObserverComparison(iatas, selection!, signal), + enabled: selection !== null, + retry: false, staleTime: 30_000, refetchOnWindowFocus: false, + }); + const observerA = useQuery({ + queryKey: ["observer", selection?.observerA], queryFn: () => getObserver(selection!.observerA), + enabled: selection !== null, staleTime: 30_000, retry: false, + }); + const observerB = useQuery({ + queryKey: ["observer", selection?.observerB], queryFn: () => getObserver(selection!.observerB), + enabled: selection !== null, staleTime: 30_000, retry: false, + }); + function compare(value: Selection) { + if (selection && Object.entries(value).every(([key, v]) => selection[key as keyof Selection] === v)) { + void result.refetch(); + return; + } + setParams((old) => { + const next = new URLSearchParams(old); + next.set("compareA", value.observerA); next.set("compareB", value.observerB); + next.set("compareSince", String(value.since)); next.set("compareUntil", String(value.until)); + return next; + }); + } + const data = result.data; + const groups = data ? [ + { name: "Only A", count: data.onlyA, color: "var(--color-primary)" }, + { name: "Both", count: data.both, color: "var(--color-green)" }, + { name: "Only B", count: data.onlyB, color: "var(--color-secondary)" }, + ] : []; + return ( +
+ +

Compare distinct flood packets reported by two observers over any retained time period.

+ {supplied && !valid &&

This comparison link has invalid or missing values. Choose observers and dates below.

} + params.get(key)).join("|")} initial={selection} onCompare={compare} /> +
+ {selection && +

A: {observerA.data?.displayName ?? selection.observerA} · B: {observerB.data?.displayName ?? selection.observerB}

+

{new Date(selection.since).toLocaleString()} to {new Date(selection.until).toLocaleString()} (local time; end excluded). Region: {iatas?.join(", ") || "All"}.

+ {result.isFetching &&

Comparing reported packets…

} + {result.isError &&

{result.error.message}

} + {data && !result.isError && <> +

{data.totalPackets.toLocaleString()} distinct flood packets

+ {data.totalPackets === 0 ?

No flood packets were reported by either observer in this period and region.

: <> +
{groups.map((g) =>
)}
+ + + {groups.map((g) => )} +
Heard byPackets% of union
{g.name}{g.count.toLocaleString()}{(g.count / data.totalPackets * 100).toFixed(1)}%
+ } +

Percentages use the union of packets heard by either observer. Repeated receptions count once. These counts reflect retained reports, not radio packet loss; an offline observer, broker interruption or expired history can affect the result.

+ } + } +
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 3159c2e9..3f03b2ca 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -6,10 +6,11 @@ import { MeshTab } from "./MeshTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; import { ObserverTab } from "./ObserverTab"; +import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "graph"]; +const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -60,6 +61,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { )} {tab === "graph" && } + {tab === "compare" && }
); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index b4bc6cad..713b5a48 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -59,6 +59,7 @@ const TAB_OPTIONS = [ { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, + { value: "compare", label: "Compare observers", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; @@ -103,8 +104,8 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props />
)} - {/* graph is topology and clock-drift is each node's latest reading — neither is time-windowed */} - {tab !== "graph" && tab !== "clockdrift" && ( + {/* Comparison has explicit dates; graph and clock drift have no rolling window. */} + {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && ( = { diff --git a/tests/api/observer-comparison.test.ts b/tests/api/observer-comparison.test.ts new file mode 100644 index 00000000..ed346029 --- /dev/null +++ b/tests/api/observer-comparison.test.ts @@ -0,0 +1,17 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getObserverComparison } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); + +it("sends the complete comparison scope, including an epoch-zero start, and consumes cancellation", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ totalPackets: 0 }) }); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getObserverComparison(["YVR", "YYJ"], { observerA: "a", observerB: "b", since: 0, until: 1000 }, controller.signal); + const [url, options] = fetcher.mock.calls[0]; + expect(new URL(url).pathname).toContain("/stats/observer-comparison"); + expect(Object.fromEntries(new URL(url).searchParams)).toEqual({ observerA: "a", observerB: "b", since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(options.signal).toBe(controller.signal); + controller.abort(); + expect(options.signal.aborted).toBe(true); +}); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx new file mode 100644 index 00000000..4b5b434a --- /dev/null +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { CompareObserversTab } from "../../../src/features/stats/CompareObserversTab"; +import { getObserverComparison, getObserversPage, getObserver } from "../../../src/api/client"; + +const region = { iatas: ["YVR"], regionKey: "YVR" }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ + getObserverComparison: vi.fn(), getObserversPage: vi.fn(), getObserver: vi.fn(), +})); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () => null })); + +const a = "11111111-1111-1111-1111-111111111111"; +const b = "22222222-2222-2222-2222-222222222222"; +const query = `?tab=Analytics&statsTab=compare&compareA=${a}&compareB=${b}&compareSince=1000&compareUntil=2000`; +const counts = { observerA: a, observerB: b, since: 1000, until: 2000, totalPackets: 4, onlyA: 1, onlyB: 2, both: 1 }; + +function Location() { return {useLocation().search}; } +function mount(url = "?statsTab=compare") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); + return render(); +} + +beforeEach(() => { + vi.resetAllMocks(); + region.iatas = ["YVR"]; region.regionKey = "YVR"; + vi.mocked(getObserversPage).mockResolvedValue({ items: [ + { id: a, displayName: "Rooftop", iata: "YVR", status: "online" }, + { id: b, displayName: "Hilltop", iata: "YVR", status: "online" }, + ], hasMore: false, nextCursor: null }); + vi.mocked(getObserver).mockImplementation(async (id) => ({ id, displayName: id === a ? "Rooftop" : "Hilltop", iata: "YVR", status: "online", publicKey: "00", firstSeen: 0, lastSeen: 0, observationCount: 0, brokers: [] })); + vi.mocked(getObserverComparison).mockResolvedValue(counts); +}); + +describe("observer comparison", () => { + it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { + mount(); + await screen.findAllByRole("option", { name: /Rooftop/ }); + expect(getObserverComparison).not.toHaveBeenCalled(); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); + fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } }); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-02-15T00:00" } }); + expect(getObserverComparison).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledWith(["YVR"], { + observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-02-15T00:00").getTime(), + }, expect.any(AbortSignal))); + expect(screen.getByLabelText("Current URL").textContent).toContain(`compareA=${a}`); + }); + + it("restores a shared comparison and shows disjoint counts and union percentages", async () => { + mount(query); + const table = await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(within(table).getByRole("row", { name: /Only A.*1.*25.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Only B.*2.*50.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Both.*1.*25.0%/ })).toBeInTheDocument(); + expect(screen.getByText(/Percentages use the union/)).toBeInTheDocument(); + }); + + it("rejects equal observers without sending an expensive query", async () => { + mount(); + await screen.findAllByRole("option", { name: /Rooftop/ }); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); + fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: a } }); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + expect(screen.getByRole("alert")).toHaveTextContent(/different observers/); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); + + it("renders an empty period without NaN percentages", async () => { + vi.mocked(getObserverComparison).mockResolvedValue({ ...counts, totalPackets: 0, onlyA: 0, onlyB: 0, both: 0 }); + mount(query); + expect(await screen.findByText(/No flood packets were reported/)).toBeInTheDocument(); + expect(document.body).not.toHaveTextContent("NaN"); + }); + + it("searches the server beyond the initial observer page", async () => { + mount(); + fireEvent.change(screen.getByLabelText("Search Observer A"), { target: { value: "Distant station" } }); + await waitFor(() => expect(getObserversPage).toHaveBeenCalledWith(["YVR"], { name: "Distant station", limit: 50 })); + }); + + it("keeps applied result labels when the draft selection changes", async () => { + mount(query); + await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(await screen.findByText("A: Rooftop · B: Hilltop")).toBeInTheDocument(); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: b } }); + expect(screen.getByText("A: Rooftop · B: Hilltop")).toBeInTheDocument(); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + }); + + it("rejects malformed shared dates without issuing a comparison", async () => { + mount(query.replace("compareUntil=2000", "compareUntil=bad")); + expect(screen.getByRole("alert")).toHaveTextContent(/invalid or missing/); + await screen.findAllByRole("option", { name: /Rooftop/ }); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); + + it("shows query failures and retries only when requested", async () => { + vi.mocked(getObserverComparison).mockRejectedValueOnce(new Error("comparison timed out; try a shorter time period")); + mount(query); + expect(await screen.findByRole("alert")).toHaveTextContent(/shorter time period/); + fireEvent.click(screen.getByRole("button", { name: "Retry comparison" })); + await screen.findByRole("table", { name: "Flood packet comparison" }); + expect(getObserverComparison).toHaveBeenCalledTimes(2); + }); +}); From 3178713bdbcb1beab751009bf679da6a91c4099c Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 19 Sep 2026 22:58:52 -0400 Subject: [PATCH 103/163] feat(nodes): display the foreign-repeater indication (#49) * feat(nodes): display the server foreign-repeater indication * style(ws): align node update event declaration --- src/features/nodes/ForeignNodeBadge.tsx | 11 +++++++++++ src/features/nodes/NodeDetailPanel.tsx | 4 +++- src/features/nodes/NodeTable.tsx | 17 +++++++++++------ src/features/nodes/node-updates.ts | 6 ++++-- src/features/nodes/types.ts | 2 ++ src/types/ws.ts | 2 ++ tests/features/nodes/NodeDetailPanel.test.tsx | 12 ++++++++++++ tests/features/nodes/node-updates.test.ts | 18 ++++++++++++++++-- 8 files changed, 61 insertions(+), 11 deletions(-) create mode 100644 src/features/nodes/ForeignNodeBadge.tsx diff --git a/src/features/nodes/ForeignNodeBadge.tsx b/src/features/nodes/ForeignNodeBadge.tsx new file mode 100644 index 00000000..2397991d --- /dev/null +++ b/src/features/nodes/ForeignNodeBadge.tsx @@ -0,0 +1,11 @@ +import { Badge } from "../../components/Badge"; +import { Tooltip } from "../../components/Tooltip"; + +export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolean }) { + if (possiblyForeign !== true) return null; + return ( + + Possibly foreign + + ); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 4debae6a..20737382 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -8,6 +8,7 @@ import { IataChip } from "../../components/IataChip"; import { formatHex, formatSnr, snrLevel, formatRadio, formatClockDrift, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import { Timestamp } from "../../components/Timestamp"; import type { NodeObservation, NodeNeighbor } from "./types"; +import { ForeignNodeBadge } from "./ForeignNodeBadge"; function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) { return ( @@ -113,11 +114,12 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o {node && ( <>
-
+
{node.name ?? formatHex(node.id)} {node.nodeTypeName} +
diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 60179f75..aa7cc169 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -16,6 +16,7 @@ import { LoadingPill } from "../../components/LoadingPill"; import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; import { nodeSearchParams } from "./node-search"; import { patchNodeSummary } from "./node-updates"; +import { ForeignNodeBadge } from "./ForeignNodeBadge"; import type { NodeSummary } from "./types"; import type { CursorPage } from "../../types/api"; import type { WsManager } from "../../api/ws-manager"; @@ -44,12 +45,15 @@ const COLUMNS: Column[] = [ header: "Type", sortValue: (node) => node.nodeTypeName, cell: (node) => ( - - {node.isObserver && ( - - )} - {node.nodeTypeName} - +
+ + {node.isObserver && ( + + )} + {node.nodeTypeName} + + +
), }, { @@ -114,6 +118,7 @@ function renderNodeCard(node: NodeSummary) { {location && · {location}} {node.knownNeighborCount > 0 && · {node.knownNeighborCount.toLocaleString()} neighbors}
+ {node.iatas && node.iatas.length > 0 && (
{node.iatas.map((entry) => ( diff --git a/src/features/nodes/node-updates.ts b/src/features/nodes/node-updates.ts index caee5902..138ef5cf 100644 --- a/src/features/nodes/node-updates.ts +++ b/src/features/nodes/node-updates.ts @@ -20,11 +20,12 @@ export function patchNodeSummary( const name = data.name || prev.name; const lat = data.lat ?? prev.lat; const lng = data.lng ?? prev.lng; + const possiblyForeign = data.possiblyForeign === undefined ? prev.possiblyForeign : data.possiblyForeign ?? undefined; // a re-advert that re-sends the same values must keep the SAME ref so patchInfinitePages no-ops // (otherwise an unchanged node would trigger a full map FeatureCollection rebuild + setData) - if (name === prev.name && lat === prev.lat && lng === prev.lng) return list; + if (name === prev.name && lat === prev.lat && lng === prev.lng && possiblyForeign === prev.possiblyForeign) return list; const updated = [...list]; - updated[idx] = { ...prev, name, lat, lng }; + updated[idx] = { ...prev, name, lat, lng, possiblyForeign }; return updated; } @@ -54,6 +55,7 @@ export function upsertNodePages( // meeting for the first time has none resolved yet, so start at 0 until a reload fills it in knownNeighborCount: 0, isObserver: data.isObserver, + possiblyForeign: data.possiblyForeign ?? undefined, }; const pages = [...old.pages]; const last = pages[pages.length - 1]!; diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 02ca6e1c..40886a67 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -20,6 +20,8 @@ export interface NodeSummary { // map's observer-pip marker variant; observerId, when present, links to that observer's detail. isObserver?: boolean; observerId?: string; + // Server verdict against its configured local border union; absence is unknown/disabled. + possiblyForeign?: boolean; } export interface Node extends NodeSummary { diff --git a/src/types/ws.ts b/src/types/ws.ts index 3b72345b..d2cbc3b0 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -101,6 +101,8 @@ export interface WsNodeUpdate { name: string; nodeType: number; nodeTypeName: string; + // Omitted leaves the prior verdict unchanged; null clears an unknown/reset position. + possiblyForeign?: boolean | null; iata: string; // decimal degrees, same as REST /nodes (api/nodes.go serializes *float64 degrees to both) lat?: number; diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index 22992a24..c71685c1 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -58,6 +58,18 @@ beforeEach(() => { }); describe("NodeDetailPanel neighbors", () => { + it("shows the server's possibly-foreign indication", async () => { + mockGetNode.mockResolvedValue({ ...node, possiblyForeign: true }); + renderPanel(); + expect(await screen.findByText("Possibly foreign")).toBeInTheDocument(); + }); + + it.each([false, undefined])("does not label unflagged nodes as foreign (%s)", async (possiblyForeign) => { + mockGetNode.mockResolvedValue({ ...node, possiblyForeign }); + renderPanel(); + await screen.findByText("Self Node"); + expect(screen.queryByText("Possibly foreign")).not.toBeInTheDocument(); + }); it("lists each neighbor's name in a Neighbors section", async () => { mockGetNodeNeighbors.mockResolvedValue([neighbor("n-1", "Neighbor A"), neighbor("n-2", "Neighbor B")]); diff --git a/tests/features/nodes/node-updates.test.ts b/tests/features/nodes/node-updates.test.ts index e7b57227..93013dea 100644 --- a/tests/features/nodes/node-updates.test.ts +++ b/tests/features/nodes/node-updates.test.ts @@ -24,6 +24,20 @@ function update(overrides: Partial): WsNodeUpdate["data"] } describe("patchNodeSummary", () => { + it("applies foreign status changes without moving coordinates and clears explicit null", () => { + const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: true })]; + const local = patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: false }))!; + expect(local[0]!.possiblyForeign).toBe(false); + expect(local).not.toBe(list); + const unknown = patchNodeSummary(local, update({ nodeId: "a", name: "Keep", lat: 0, lng: 0, possiblyForeign: null }))!; + expect(unknown[0]!.possiblyForeign).toBeUndefined(); + expect(list[0]!.possiblyForeign).toBe(true); + }); + + it("retains the flag and list identity when an advert omits the position/classification", () => { + const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20, possiblyForeign: true })]; + expect(patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: undefined, lng: undefined }))).toBe(list); + }); it("returns the list unchanged (same ref) when it is undefined", () => { expect(patchNodeSummary(undefined, update({}))).toBeUndefined(); }); @@ -84,12 +98,12 @@ describe("upsertNodePages", () => { const old = pages([node({ id: "a" })], [node({ id: "b" })]); const out = upsertNodePages( old, - update({ nodeId: "new1", name: "Fresh", nodeTypeName: "companion", publicKey: "pk2", lat: 50.5, lng: -100.25, isObserver: false, iatas: [] }), + update({ nodeId: "new1", name: "Fresh", nodeTypeName: "repeater", publicKey: "pk2", lat: 50.5, lng: -100.25, isObserver: false, iatas: [], possiblyForeign: true }), )!; expect(out).not.toBe(old); expect(out.pages[0]).toBe(old.pages[0]); // earlier pages keep their refs expect(out.pages[1]!.items.map((n) => n.id)).toEqual(["b", "new1"]); - expect(out.pages[1]!.items[1]).toMatchObject({ name: "Fresh", lat: 50.5, lng: -100.25 }); + expect(out.pages[1]!.items[1]).toMatchObject({ name: "Fresh", lat: 50.5, lng: -100.25, possiblyForeign: true }); }); it("keeps the same ref for a re-advert that changes nothing", () => { From e01c090c8e33e2ee352dba79d983a9e85b1b19ae Mon Sep 17 00:00:00 2001 From: n30nex Date: Sun, 20 Sep 2026 11:41:43 -0400 Subject: [PATCH 104/163] feat(stats): add hourly traffic and region-aware scope analytics (#53) Adds the Traffic and Scopes analytics tabs. Includes the hourly traffic work from #52, which this PR contains in full. Closes #50. Closes #51. --- src/App.tsx | 2 +- src/api/client.ts | 10 +-- src/features/stats/MeshTab.tsx | 4 +- src/features/stats/ScopesTab.tsx | 59 ++++++++++++ src/features/stats/StatsOverview.tsx | 8 +- src/features/stats/StatsSubHeader.tsx | 4 +- src/features/stats/TrafficTab.tsx | 57 ++++++++++++ src/features/stats/echarts-setup.ts | 7 +- src/features/stats/scopes.ts | 25 ++++++ src/features/stats/traffic.ts | 90 +++++++++++++++++++ src/features/stats/types.ts | 2 +- src/features/stats/useStats.ts | 22 +++-- src/hooks/useRegion.tsx | 3 +- tests/api/analytics-client.test.ts | 20 +++++ tests/features/stats/ScopesTab.test.tsx | 34 +++++++ tests/features/stats/TrafficTab.test.tsx | 44 +++++++++ .../stats/analytics-navigation.test.tsx | 30 +++++++ tests/features/stats/scope-query.test.tsx | 29 ++++++ tests/features/stats/scopes.test.ts | 16 ++++ tests/features/stats/traffic-query.test.tsx | 34 +++++++ tests/features/stats/traffic.test.ts | 54 +++++++++++ 21 files changed, 534 insertions(+), 20 deletions(-) create mode 100644 src/features/stats/ScopesTab.tsx create mode 100644 src/features/stats/TrafficTab.tsx create mode 100644 src/features/stats/scopes.ts create mode 100644 src/features/stats/traffic.ts create mode 100644 tests/api/analytics-client.test.ts create mode 100644 tests/features/stats/ScopesTab.test.tsx create mode 100644 tests/features/stats/TrafficTab.test.tsx create mode 100644 tests/features/stats/analytics-navigation.test.tsx create mode 100644 tests/features/stats/scope-query.test.tsx create mode 100644 tests/features/stats/scopes.test.ts create mode 100644 tests/features/stats/traffic-query.test.tsx create mode 100644 tests/features/stats/traffic.test.ts diff --git a/src/App.tsx b/src/App.tsx index 950ac213..573822e1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -308,7 +308,7 @@ function AppInner() { />
-
+
}> {tabContent[activeTab]} diff --git a/src/api/client.ts b/src/api/client.ts index 011408b3..c91dbdfd 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -305,8 +305,8 @@ export function getStatsOverview(iatas?: string[]): Promise { return request("/stats/overview", { iatas: iatasParam(iatas) }); } -export function getStatsObservations(iatas?: string[], since?: number): Promise { - return request("/stats/observations", { iatas: iatasParam(iatas), since }); +export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise { + return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal); } export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { @@ -344,9 +344,9 @@ export function getClockDrift(iatas?: string[], limit = 100): Promise { - return request("/stats/scopes"); +// aggregate (packet/observer/node counts), filtered by the selected IATAs. +export function getStatsScopes(iatas?: string[], signal?: AbortSignal): Promise { + return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal); } export function getObserverTelemetry( diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index c4e5e365..84d33c86 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -143,10 +143,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { - Scopes · all regions · all time}> + Scopes · selected region · retained data}> {scopes.isError ? (
Failed to load
- ) : scopes.isLoading ? ( + ) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? (
Loading…
) : scopeRows.length === 0 ? (
No data
diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx new file mode 100644 index 00000000..76b0270d --- /dev/null +++ b/src/features/stats/ScopesTab.tsx @@ -0,0 +1,59 @@ +import { useMemo, useState } from "react"; +import { useScopes } from "./useStats"; +import { useChartColors } from "./chartTheme"; +import { Card, ChartCard, StatCard } from "./cards"; +import { scopeChartOption, scopeSummary } from "./scopes"; +import { formatCount } from "../../lib/formatters"; + +export function ScopesTab() { + const query = useScopes(); + const colors = useChartColors(); + const [search, setSearch] = useState(""); + const loading = query.isPending || query.isLoading || query.isPlaceholderData; + const unavailable = loading || query.isError; + const all = useMemo(() => unavailable ? [] : (query.data ?? []), [query.data, unavailable]); + const rows = useMemo(() => all.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase())).sort((a, b) => b.packetCount - a.packetCount || a.name.localeCompare(b.name)), [all, search]); + const totals = useMemo(() => scopeSummary(rows), [rows]); + const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors), [rows, colors]); + const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors), [rows, colors]); + const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors), [rows, colors]); + const value = (number: number) => unavailable ? "—" : formatCount(number); + const height = Math.max(180, Math.min(13, rows.length) * 28 + 16); + + return ( +
+
+

Transport scopes

Retained data and memberships for the selected region.

+ +
+ + {query.isError &&

Could not load scopes. Try refreshing.

} +
+ + + + +
+

An observer can appear in more than one scope. Global observer counts include stored memberships; regional counts use retained scoped receptions. Nodes are counted in their default scope. These counts have no selected time window.

+
+ + + + {unavailable ? "—" : `${rows.length} of ${all.length} scopes`}}> + {unavailable ?

{query.isError ? "Data unavailable" : "Loading scopes…"}

: !rows.length ?

{search ? "No scopes match this search." : "No scope data available."}

: ( +
+ + {rows.map((row) => + + + )} +
ScopePacketsObserversNodes
{row.name}{row.packetCount.toLocaleString()}{row.observerCount.toLocaleString()}{row.nodeCount.toLocaleString()}
+ )} +

Charts show the largest 12 scopes plus any remainder. The table keeps all matching scopes.

+
+
+
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 3f03b2ca..f3ba1d9f 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -3,6 +3,8 @@ import { useSearchParams } from "react-router-dom"; import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; +import { TrafficTab } from "./TrafficTab"; +import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; import { ObserverTab } from "./ObserverTab"; @@ -10,7 +12,7 @@ import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "compare", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -51,10 +53,12 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]); return ( -
+
{tab === "mesh" && } + {tab === "traffic" && } + {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } {tab === "observer" && ( diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 713b5a48..fc03ea80 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -56,6 +56,8 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, + { value: "traffic", label: "Traffic", icon: }, + { value: "scopes", label: "Scopes", icon: }, { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, @@ -105,7 +107,7 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
)} {/* Comparison has explicit dates; graph and clock drift have no rolling window. */} - {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && ( + {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && ( trafficModel(unavailable ? [] : (query.data ?? []), range, query.dataUpdatedAt), [query.data, query.dataUpdatedAt, range, unavailable]); + const trend = useMemo(() => trafficTrendOption(model, colors), [model, colors]); + const heatmap = useMemo(() => trafficHeatmapOption(model, colors), [model, colors]); + const share = useMemo(() => { + const option = donutOption(model.series.map((area, i) => ({ name: area.name, value: area.total, color: colors.series[i] })), colors, formatCount(model.total), "RECEPTIONS"); + return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: "Reception share by IATA. Exact counts and percentages follow in the table." } } }; + }, [model, colors]); + const value = (number: number) => unavailable ? "—" : formatCount(number); + + return ( +
+
+

Traffic

When and where observers report receptions.

+ +
+ {query.isError &&

Could not load traffic. Try refreshing.

} +
+ + area.total > 0).length)} accent={colors.green} /> + + +
+

Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.

+ Available hourly records} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> + +
+ + + {unavailable ?

{query.isError ? "Data unavailable" : "Loading traffic…"}

: !model.areas.length ?

No retained receptions in this window.

: ( +
+ + {model.areas.map((area, i) => + + + )} +
IATAReceptionsShare
{area.name}{area.total.toLocaleString()}{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%
+ )} +

Charts group smaller areas into “Other IATAs”; this table keeps every area.

+
+
+
+ ); +} diff --git a/src/features/stats/echarts-setup.ts b/src/features/stats/echarts-setup.ts index b41cf57c..aed9685d 100644 --- a/src/features/stats/echarts-setup.ts +++ b/src/features/stats/echarts-setup.ts @@ -3,7 +3,7 @@ // the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps // the core API directly instead. import * as echarts from "echarts/core"; -import { LineChart, BarChart, PieChart, GaugeChart, GraphChart } from "echarts/charts"; +import { LineChart, BarChart, PieChart, GaugeChart, GraphChart, HeatmapChart } from "echarts/charts"; import { GridComponent, TitleComponent, @@ -12,6 +12,8 @@ import { GraphicComponent, DataZoomComponent, MarkLineComponent, + VisualMapComponent, + AriaComponent, } from "echarts/components"; import { CanvasRenderer } from "echarts/renderers"; @@ -21,6 +23,7 @@ echarts.use([ PieChart, GaugeChart, GraphChart, + HeatmapChart, GridComponent, TitleComponent, TooltipComponent, @@ -28,6 +31,8 @@ echarts.use([ GraphicComponent, DataZoomComponent, MarkLineComponent, + VisualMapComponent, + AriaComponent, CanvasRenderer, ]); diff --git a/src/features/stats/scopes.ts b/src/features/stats/scopes.ts new file mode 100644 index 00000000..e8a6bf0a --- /dev/null +++ b/src/features/stats/scopes.ts @@ -0,0 +1,25 @@ +import type { ScopeStats } from "./types"; +import { leaderboardOption } from "./chartOptions"; +import { tooltipStyle, type ChartColors } from "./chartTheme"; +import type { EChartsOption } from "./echarts-setup"; + +export type ScopeMetric = "packetCount" | "observerCount" | "nodeCount"; + +export function scopeSummary(rows: ScopeStats[]) { + return rows.reduce((summary, row) => ({ + active: summary.active + (row.packetCount || row.observerCount || row.nodeCount ? 1 : 0), + packets: summary.packets + row.packetCount, + memberships: summary.memberships + row.observerCount, + nodes: summary.nodes + row.nodeCount, + }), { active: 0, packets: 0, memberships: 0, nodes: 0 }); +} + +export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors: ChartColors): EChartsOption { + const indices = new Map(rows.map((row) => row.name).sort().map((name, index) => [name, index])); + const ranked = rows.map((row) => ({ name: row.name, value: row[metric], color: colors.series[(indices.get(row.name) ?? 0) % colors.series.length] ?? colors.primary })) + .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); + const shown = ranked.slice(0, 12); + if (ranked.length > 12) shown.push({ name: "Other scopes", value: ranked.slice(12).reduce((sum, row) => sum + row.value, 0), color: colors.textDim }); + return { ...leaderboardOption(shown, colors, 126), tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, + aria: { enabled: true, label: { description: "Counts by transport scope. Exact values for all matching scopes are listed in the table." } } }; +} diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts new file mode 100644 index 00000000..bb574ec3 --- /dev/null +++ b/src/features/stats/traffic.ts @@ -0,0 +1,90 @@ +import type { EChartsOption } from "./echarts-setup"; +import { tooltipStyle, type ChartColors } from "./chartTheme"; +import { RANGE_MS, type ObservationPoint, type StatsRange } from "./types"; + +const HOUR = 3_600_000; +const MONO = "JetBrains Mono, monospace"; + +export function trafficModel(points: ObservationPoint[], range: StatsRange, asOf: number) { + const end = Math.floor(asOf / HOUR) * HOUR; + const length = RANGE_MS[range] / HOUR; + const start = end - (length - 1) * HOUR; + const buckets = new Map>(); + const totals = new Map(); + for (const point of points) { + if (!Number.isFinite(point.hour) || point.hour < start || point.hour > end || !Number.isFinite(point.observationCount) || point.observationCount < 0) continue; + const hour = Math.floor(point.hour / HOUR) * HOUR; + const name = point.iata.trim().toUpperCase() || "Unassigned"; + const bucket = buckets.get(hour) ?? new Map(); + bucket.set(name, (bucket.get(name) ?? 0) + point.observationCount); + buckets.set(hour, bucket); + totals.set(name, (totals.get(name) ?? 0) + point.observationCount); + } + const areas = [...totals].map(([name, total]) => ({ name, total })).sort((a, b) => b.total - a.total || a.name.localeCompare(b.name)); + const names = areas.slice(0, 7).map((area) => area.name); + const hours = Array.from({ length }, (_, i) => { + const hour = start + i * HOUR; + const bucket = buckets.get(hour); + return { hour, total: bucket ? [...bucket.values()].reduce((a, b) => a + b, 0) : null }; + }); + const series = names.map((name) => ({ + name, total: totals.get(name) ?? 0, + values: hours.map(({ hour }) => buckets.has(hour) ? (buckets.get(hour)?.get(name) ?? 0) : null), + })); + if (areas.length > names.length) { + series.push({ name: "Other IATAs", total: areas.slice(7).reduce((sum, area) => sum + area.total, 0), + values: hours.map(({ hour, total }) => total === null ? null : total - names.reduce((sum, name) => sum + (buckets.get(hour)?.get(name) ?? 0), 0)) }); + } + const days = [...new Set(hours.map(({ hour }) => new Date(hour).toISOString().slice(0, 10)))]; + const heatmap: number[][] = []; + let peak: { hour: number; total: number } | null = null; + for (const { hour, total } of hours) { + if (total === null) continue; + heatmap.push([new Date(hour).getUTCHours(), days.indexOf(new Date(hour).toISOString().slice(0, 10)), total]); + if (!peak || total > peak.total) peak = { hour, total }; + } + return { hours, days, heatmap, peak, areas, series, reportedHours: buckets.size, total: areas.reduce((sum, area) => sum + area.total, 0) }; +} + +export type TrafficModel = ReturnType; + +export function trafficTrendOption(model: TrafficModel, c: ChartColors): EChartsOption { + const present = model.hours.filter((hour) => hour.total !== null); + const first = present[0]?.hour; + const last = present.at(-1)?.hour; + return { + animation: false, useUTC: true, backgroundColor: "transparent", + aria: { enabled: true, label: { description: "Hourly reported receptions by IATA. Gaps mean no retained hourly record. Exact totals follow in the data table." } }, + grid: { left: 54, right: 18, top: 42, bottom: 28 }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + legend: { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: c.textNormal, fontSize: 10 }, inactiveColor: c.textDim }, + xAxis: { type: "time", min: first === last && first !== undefined ? first - HOUR / 2 : first, max: first === last && last !== undefined ? last + HOUR / 2 : last, + axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } }, splitLine: { show: false } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.border, opacity: 0.4 } } }, + series: model.series.map((series, index) => ({ + name: series.name, type: "line", stack: "receptions", connectNulls: false, smooth: false, + symbol: "circle", symbolSize: 5, showSymbol: present.length <= 2, + data: model.hours.map((hour, i) => [hour.hour, series.values[i]]), + lineStyle: { color: c.series[index], width: 1.5 }, itemStyle: { color: c.series[index] }, areaStyle: { color: c.series[index], opacity: 0.45 }, + })), + }; +} + +export function trafficHeatmapOption(model: TrafficModel, c: ChartColors): EChartsOption { + return { + animation: false, backgroundColor: "transparent", + aria: { enabled: true, label: { description: "Activity heatmap by UTC day and hour. Brighter cells indicate more reported receptions; blank cells have no retained record." } }, + grid: { left: 58, right: 14, top: 8, bottom: 62 }, + tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(c), formatter: (item: { value: unknown }) => { + const value = item.value as [number, number, number]; + return `${model.days[value[1]]} ${String(value[0]).padStart(2, "0")}:00 UTC\n${value[2].toLocaleString()} receptions`; + } }, + xAxis: { type: "category", data: Array.from({ length: 24 }, (_, h) => String(h).padStart(2, "0")), + axisLabel: { color: c.textMuted, fontSize: 9, interval: 2 }, axisTick: { show: false }, axisLine: { show: false }, splitArea: { show: true, areaStyle: { color: [c.bgRaised, c.bgSurface] } } }, + yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false }, + axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } }, + visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130, + text: ["More", "Fewer"], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } }, + series: [{ name: "Receptions", type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }], + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 51fbc195..d33cce69 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -153,7 +153,7 @@ export interface ObserverActivity { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; +export type StatsTab = "mesh" | "traffic" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 1c5f72cb..4736e7c1 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -37,10 +37,14 @@ export function useStatsOverview() { } export function useStatsObservations(range: StatsRange) { - const { iatas, regionKey } = useRegion(); + const { iatas, regionKey, isResolved } = useRegion(); return useQuery({ - queryKey: ["stats-observations", regionKey, range], - queryFn: () => getStatsObservations(iatas, sinceFor(range)), + queryKey: ["stats-observations", isResolved === false ? `${regionKey}:pending` : regionKey, range], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + return getStatsObservations(iatas, sinceFor(range), signal); + }, ...common, // feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh refetchInterval: 60_000, @@ -121,11 +125,17 @@ export function useClockDrift(limit = 100) { }); } -// scopes are reported globally by the backend (no region filter), so the key is region-independent +// Scope counts have no time window, but the selected region changes their membership. export function useScopes() { + const { iatas, regionKey, isResolved } = useRegion(); return useQuery({ - queryKey: ["stats-scopes"], - queryFn: getStatsScopes, + queryKey: ["stats-scopes", isResolved === false ? `${regionKey}:pending` : regionKey], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + return getStatsScopes(iatas, signal); + }, ...common, + refetchInterval: 60_000, }); } diff --git a/src/hooks/useRegion.tsx b/src/hooks/useRegion.tsx index bb6310e0..fac78248 100644 --- a/src/hooks/useRegion.tsx +++ b/src/hooks/useRegion.tsx @@ -80,6 +80,7 @@ export function useRegions(): RegionsData { export interface RegionFilter { iatas: string[] | undefined; // resolved member IATAs to query; undefined = all regions regionKey: string; // stable query-key fragment ("*" = all) + isResolved?: boolean; // false while a selected region's member IATAs are unavailable } // The resolved geographic filter consumers pass to queries: the flattened IATA list plus a stable key. @@ -89,6 +90,6 @@ export function useRegion(): RegionFilter { return useMemo(() => { const iatas = resolveIatas(selection, regionIatas); - return { iatas, regionKey: toRegionKey(iatas) }; + return { iatas, regionKey: toRegionKey(iatas), isResolved: selection.regions.every((slug) => regionIatas.has(slug)) }; }, [selection, regionIatas]); } diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts new file mode 100644 index 00000000..b733ab0d --- /dev/null +++ b/tests/api/analytics-client.test.ts @@ -0,0 +1,20 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getStatsObservations, getStatsScopes } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); + +it("forwards region and cancellation to both aggregate endpoints, omitting an empty global filter", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getStatsScopes(["YVR", "YOW"], controller.signal); + expect(new URL(fetcher.mock.calls[0]![0] as string).searchParams.get("iatas")).toBe("YVR,YOW"); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); + await getStatsScopes([]); + expect(new URL(fetcher.mock.calls[1]![0] as string).searchParams.has("iatas")).toBe(false); + await getStatsObservations(["YOW"], 1234, controller.signal); + const url = new URL(fetcher.mock.calls[2]![0] as string); + expect(url.pathname).toContain("/stats/observations"); + expect(url.searchParams.get("since")).toBe("1234"); + expect(url.searchParams.get("iatas")).toBe("YOW"); +}); diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx new file mode 100644 index 00000000..d6128eb1 --- /dev/null +++ b/tests/features/stats/ScopesTab.test.tsx @@ -0,0 +1,34 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { ScopesTab } from "../../../src/features/stats/ScopesTab"; +const query = { data: [ + { name: "#west", packetCount: 3, observerCount: 2, nodeCount: 2 }, + { name: "#east", packetCount: 2, observerCount: 2, nodeCount: 3 }, +], isPending: false, isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +vi.mock("../../../src/features/stats/useStats", () => ({ useScopes: () => query })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +beforeEach(() => { vi.clearAllMocks(); query.isError = false; query.isPending = false; query.isPlaceholderData = false; }); + +it("shows exact scope counts, keeps membership semantics explicit and filters the whole view", () => { + render(); + const table = screen.getByRole("table", { name: "Scope counts" }); + expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); + expect(screen.getByText("Observer memberships")).toBeInTheDocument(); + expect(screen.getByText(/An observer can appear/)).toBeInTheDocument(); + expect(screen.getByText(/Retained data/)).toBeInTheDocument(); + fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } }); + expect(screen.queryByText("#east")).not.toBeInTheDocument(); + expect(screen.getByText(/1 of 2 scopes/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh scopes" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); + +it("hides stale values while a region change is pending and on errors", () => { + query.isPlaceholderData = true; + const { rerender } = render(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + query.isPlaceholderData = false; query.isError = true; rerender(); + expect(screen.getByRole("alert")).toHaveTextContent("Could not load scopes"); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); +}); diff --git a/tests/features/stats/TrafficTab.test.tsx b/tests/features/stats/TrafficTab.test.tsx new file mode 100644 index 00000000..4f82a4c1 --- /dev/null +++ b/tests/features/stats/TrafficTab.test.tsx @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { TrafficTab } from "../../../src/features/stats/TrafficTab"; +import { useStatsObservations } from "../../../src/features/stats/useStats"; + +const query = { data: [{ hour: Date.UTC(2026, 8, 19, 12), iata: "YOW", observationCount: 1200, uniquePackets: 9999, activeObservers: 88 }], dataUpdatedAt: Date.UTC(2026, 8, 19, 12, 30), isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +vi.mock("../../../src/features/stats/useStats", () => ({ useStatsObservations: vi.fn(() => query) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); + +beforeEach(() => { + query.isLoading = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false; + vi.clearAllMocks(); +}); + +describe("Traffic page", () => { + it("shows exact reception counts, UTC/missing-history guidance and a refresh action", () => { + render(); + expect(useStatsObservations).toHaveBeenCalledWith("24h"); + const table = screen.getByRole("table", { name: "Receptions by IATA" }); + expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument(); + expect(screen.queryByText("9,999")).not.toBeInTheDocument(); + expect(screen.getByText(/Blank hours/)).toHaveTextContent("retained"); + expect(screen.getByText(/UTC/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" })); + expect(query.refetch).toHaveBeenCalledOnce(); + }); + + it("does not display previous-region data while a new query is loading", () => { + query.isPlaceholderData = true; + render(); + expect(screen.queryByText("YOW")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + }); + + it("provides a visible error state and retry without stale chart values", () => { + query.isError = true; + render(); + expect(screen.getByRole("alert")).toHaveTextContent("Could not load traffic"); + expect(screen.queryByText("YOW")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh traffic" })); + expect(query.refetch).toHaveBeenCalledOnce(); + }); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx new file mode 100644 index 00000000..48b334bd --- /dev/null +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -0,0 +1,30 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { expect, it, vi } from "vitest"; +import { StatsOverview } from "../../../src/features/stats/StatsOverview"; +import type { WsManager } from "../../../src/api/ws-manager"; + +vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) =>

Traffic range {range}

})); +vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh charts

})); +vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); +function Location() { return {useLocation().search}; } + +it("opens Traffic from a shared URL and keeps unrelated selections when changing the range", () => { + render(); + expect(screen.getByText("Traffic range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Traffic" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "30d" })); + expect(screen.getByText("Traffic range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("observerId=kept"); + fireEvent.click(screen.getByRole("button", { name: "Mesh" })); + expect(screen.getByText("Mesh charts")).toBeInTheDocument(); +}); + +it("opens Scopes without a misleading range control and preserves the chosen range for Traffic", () => { + render(); + expect(screen.getByText("Scope charts")).toBeInTheDocument(); + expect(screen.queryByRole("group", { name: "Time range" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Traffic" })); + expect(screen.getByText("Traffic range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); diff --git a/tests/features/stats/scope-query.test.tsx b/tests/features/stats/scope-query.test.tsx new file mode 100644 index 00000000..50f72950 --- /dev/null +++ b/tests/features/stats/scope-query.test.tsx @@ -0,0 +1,29 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useScopes } from "../../../src/features/stats/useStats"; +import { getStatsScopes } from "../../../src/api/client"; + +const region = { iatas: ["YVR"] as string[] | undefined, regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getStatsScopes: vi.fn(() => Promise.resolve([])) })); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("sends the selected IATAs and fetches a separate global query when the filter is cleared", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender, unmount } = renderHook(() => useScopes(), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(["YVR"], expect.any(AbortSignal))); + region.iatas = undefined; region.regionKey = "*"; rerender(); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(undefined, expect.any(AbortSignal))); + expect(client.getQueryCache().findAll({ queryKey: ["stats-scopes"] })).toHaveLength(2); + unmount(); client.clear(); +}); + +it("does not fetch a global fallback for unresolved selected regions", () => { + region.isResolved = false; + const client = new QueryClient(); + const { unmount } = renderHook(() => useScopes(), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(getStatsScopes).not.toHaveBeenCalled(); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/scopes.test.ts b/tests/features/stats/scopes.test.ts new file mode 100644 index 00000000..b74ade4b --- /dev/null +++ b/tests/features/stats/scopes.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vitest"; +import { scopeSummary, scopeChartOption } from "../../../src/features/stats/scopes"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; + +it("totals memberships explicitly and bounds charts without dropping the remainder", () => { + const rows = Array.from({ length: 20 }, (_, i) => ({ name: `#s${i}`, packetCount: i + 1, observerCount: 2, nodeCount: 1 })); + const original = JSON.stringify(rows); + expect(scopeSummary(rows)).toEqual({ active: 20, packets: 210, memberships: 40, nodes: 20 }); + const option = scopeChartOption(rows, "packetCount", readChartColors()); + expect(option).toMatchObject({ animation: false, tooltip: { renderMode: "richText" } }); + expect(JSON.stringify(option)).toContain("Other scopes"); + const bars = option as { series: { data: { value: number }[] }[] }; + expect(bars.series[0]!.data.reduce((sum, row) => sum + row.value, 0)).toBe(210); + expect(JSON.stringify(rows)).toBe(original); + expect(scopeSummary([])).toEqual({ active: 0, packets: 0, memberships: 0, nodes: 0 }); +}); diff --git a/tests/features/stats/traffic-query.test.tsx b/tests/features/stats/traffic-query.test.tsx new file mode 100644 index 00000000..e07057a8 --- /dev/null +++ b/tests/features/stats/traffic-query.test.tsx @@ -0,0 +1,34 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useStatsObservations } from "../../../src/features/stats/useStats"; +import { getStatsObservations } from "../../../src/api/client"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getStatsObservations: vi.fn(() => new Promise(() => {})) })); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("waits for a selected region to resolve instead of making an unfiltered request", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getStatsObservations).not.toHaveBeenCalled(); + unmount(); client.clear(); +}); + +it("cancels the old region request and uses a new cache key when filters change", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsObservations).toHaveBeenCalledOnce()); + const first = vi.mocked(getStatsObservations).mock.calls[0]!; + expect(first[0]).toEqual(["YVR"]); + expect(first[1]).toBeGreaterThan(Date.now() - 24 * 3_600_000 - 5_000); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender(); + await waitFor(() => expect(getStatsObservations).toHaveBeenCalledTimes(2)); + expect(first[2]?.aborted).toBe(true); + expect(vi.mocked(getStatsObservations).mock.calls[1]?.[0]).toEqual(["YOW"]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/traffic.test.ts b/tests/features/stats/traffic.test.ts new file mode 100644 index 00000000..583f000f --- /dev/null +++ b/tests/features/stats/traffic.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { trafficModel, trafficHeatmapOption, trafficTrendOption } from "../../../src/features/stats/traffic"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { ObservationPoint } from "../../../src/features/stats/types"; + +const hour = 3_600_000; +const now = Date.UTC(2026, 8, 19, 12, 30); +const end = Date.UTC(2026, 8, 19, 12); +const point = (time: number, iata: string, count: number): ObservationPoint => ({ hour: time, iata, observationCount: count, uniquePackets: 100, activeObservers: 50 }); + +describe("traffic exploration", () => { + it("counts receptions, keeps missing hours null, and anchors the bounded window in UTC", () => { + const data = [point(end - 2 * hour, "YVR", 2), point(end - 2 * hour, "YOW", 3), point(end, "YVR", 7)]; + const before = JSON.stringify(data); + const model = trafficModel(data, "24h", now); + expect(model.hours).toHaveLength(24); + expect(model.hours.at(-2)?.total).toBeNull(); + expect(model.hours.at(-3)?.total).toBe(5); + expect(model.total).toBe(12); + expect(model.reportedHours).toBe(2); + expect(model.areas.map((area) => [area.name, area.total])).toEqual([["YVR", 9], ["YOW", 3]]); + expect(model.peak?.hour).toBe(end); + expect(model.peak?.total).toBe(7); + expect(model.days).toEqual(["2026-09-18", "2026-09-19"]); + expect(model.heatmap).toContainEqual([12, 1, 7]); + expect(model.heatmap).not.toContainEqual([11, 1, 0]); + expect(JSON.stringify(data)).toBe(before); + }); + + it("groups smaller IATAs without losing counts and excludes out-of-window/future rows", () => { + const data = Array.from({ length: 10 }, (_, i) => point(end, `X${i}`, i + 1)); + data.push(point(end - 25 * hour, "OLD", 1_000), point(end + hour, "FUT", 1_000)); + const model = trafficModel(data, "24h", now); + expect(model.total).toBe(55); + expect(model.series).toHaveLength(8); + expect(model.series.at(-1)?.name).toBe("Other IATAs"); + expect(model.series.reduce((sum, series) => sum + series.total, 0)).toBe(55); + expect(model.series.reduce((sum, series) => sum + (series.values.at(-1) ?? 0), 0)).toBe(55); + expect(model.series.at(-1)?.total).toBe(6); + expect(trafficModel(data, "30d", now).hours).toHaveLength(720); + }); + + it("handles empty data without invented traffic and exposes safe, gap-preserving chart options", () => { + const model = trafficModel([], "7d", now); + expect(model.total).toBe(0); + expect(model.peak).toBeNull(); + expect(model.heatmap).toEqual([]); + expect(model.hours.every((point) => point.total === null)).toBe(true); + const populated = trafficModel([point(end, "YOW", 1)], "24h", now); + const colors = readChartColors(); + expect(trafficHeatmapOption(populated, colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, visualMap: { min: 0, max: 1 } }); + expect(trafficTrendOption(populated, colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, series: [{ connectNulls: false }] }); + }); +}); From c2ab29a540922ea563fecb5915dece523a1bfd58 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 23 Sep 2026 20:11:26 -0400 Subject: [PATCH 105/163] fix(stats): distinguish analytics navigation icons (#59) --- src/features/stats/StatsSubHeader.tsx | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index fc03ea80..0d76fad4 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -3,6 +3,25 @@ import { SelectDropdown } from "../../components/SelectDropdown"; import { useIsMobile } from "../../hooks/useMediaQuery"; import type { StatsRange, StatsTab } from "./types"; +function TrafficIcon() { + return + + ; +} + +function ScopesIcon() { + return + + ; +} + +function CompareIcon() { + return + + + ; +} + function MeshIcon() { return ( @@ -56,12 +75,12 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, - { value: "traffic", label: "Traffic", icon: }, - { value: "scopes", label: "Scopes", icon: }, + { value: "traffic", label: "Traffic", icon: }, + { value: "scopes", label: "Scopes", icon: }, { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, { value: "observer", label: "Observer", icon: }, - { value: "compare", label: "Compare observers", icon: }, + { value: "compare", label: "Compare observers", icon: }, { value: "graph", label: "Neighbour Graph", icon: }, ]; From 2405e1ac9693ce6dbacb676b3b3b49d5f6ee8981 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 23 Sep 2026 20:14:03 -0400 Subject: [PATCH 106/163] feat(stats): add RF and signal analytics (#55) --- src/api/client.ts | 5 ++ src/features/stats/SignalTab.tsx | 73 +++++++++++++++++++ src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 7 ++ src/features/stats/signal.ts | 69 ++++++++++++++++++ src/features/stats/types.ts | 22 +++++- src/features/stats/useSignalStats.ts | 22 ++++++ tests/api/analytics-client.test.ts | 13 +++- tests/features/stats/SignalTab.test.tsx | 29 ++++++++ .../stats/analytics-navigation.test.tsx | 10 +++ tests/features/stats/signal-query.test.tsx | 33 +++++++++ tests/features/stats/signal.test.ts | 37 ++++++++++ 12 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 src/features/stats/SignalTab.tsx create mode 100644 src/features/stats/signal.ts create mode 100644 src/features/stats/useSignalStats.ts create mode 100644 tests/features/stats/SignalTab.test.tsx create mode 100644 tests/features/stats/signal-query.test.tsx create mode 100644 tests/features/stats/signal.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index c91dbdfd..5f39f321 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -6,6 +6,7 @@ import type { ObserverSummary, Observer, AdvertObservation } from "../features/o import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; import type { StatsOverview, + SignalStats, ObserverComparison, ObservationPoint, PayloadBreakdownItem, @@ -309,6 +310,10 @@ export function getStatsObservations(iatas?: string[], since?: number, signal?: return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal); } +export function getSignalStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise { + return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal); +} + export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since }); } diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx new file mode 100644 index 00000000..a6e40b0c --- /dev/null +++ b/src/features/stats/SignalTab.tsx @@ -0,0 +1,73 @@ +import { useMemo } from "react"; +import { formatCount } from "../../lib/formatters"; +import { Card, ChartCard, StatCard } from "./cards"; +import { useChartColors } from "./chartTheme"; +import { signalBinLabel, signalCoverageOption, signalHistogramOption, signalHours, signalTrendOption } from "./signal"; +import { useSignalStats } from "./useSignalStats"; +import type { StatsRange } from "./types"; + +const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); +const average = (value: number | null | undefined, unit = "") => value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; + +export function SignalTab({ range }: { range: StatsRange }) { + const query = useSignalStats(range); + const c = useChartColors(); + const loading = query.isPending || query.isPlaceholderData; + const data = loading || query.isError ? undefined : query.data; + const hours = useMemo(() => signalHours(data), [data]); + const charts = useMemo(() => ({ + snr: signalHistogramOption(data?.snr, "SNR", "dB", c), rssi: signalHistogramOption(data?.rssi, "RSSI", "dBm", c), + snrTrend: signalTrendOption(hours, "snr", c), rssiTrend: signalTrendOption(hours, "rssi", c), coverage: signalCoverageOption(data, c), + }), [data, hours, c]); + const state = { isLoading: loading, isError: query.isError }; + + return ( +
+
+

RF / Signal

The signal behind reported receptions.

+ +
+ {query.isError &&

Could not load signal data. Try refreshing or choosing a shorter time period.

} +
+ + {average(data?.snr.average, "dB")}} accent={c.secondary} /> + {average(data?.rssi.average, "dBm")}} accent={c.green} /> + +
+

Signal measured by observers on the last hop. Charts count retained receptions and use UTC time.

+ {data &&

Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours do not establish an outage.

} +
+ + + + +
+
+ + + {!data ?

{query.isError ? "Data unavailable" : "Loading signal…"}

: !data.receptions ?

No retained receptions in this window.

: ( + + + {(["snr", "rssi"] as const).map((metric) => )} +
MetricSamplesMissingShare
{metric.toUpperCase()}{data[metric].samples.toLocaleString()}{(data.receptions - data[metric].samples).toLocaleString()}{(100 * data[metric].samples / data.receptions).toFixed(1)}%
+ )} +

Each mean weights every available reception equally. Missing includes absent, invalid and zero/zero unavailable readings; a measured zero SNR remains valid. SNR and RSSI can have different sample counts. These readings do not measure end-to-end quality or packet loss, and colors are not quality thresholds.

+
+
+ {data && data.receptions > 0 &&
+ Exact histogram and hourly values +
+ {(["snr", "rssi"] as const).map((metric) => + + {data[metric].histogram.map((bin, i) => )} +
{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}Samples
{signalBinLabel(bin)}{bin.count.toLocaleString()}
)} +
+

Bins include the lower bound and exclude the upper bound. The outer bins include all values beyond the display range. Hourly means below are rounded to one decimal place.

+
+ + {data.hourly.map((row) => )} +
UTC hourReceptionsSNR samplesMean dBRSSI samplesMean dBm
{utc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
+
} +
+ ); +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index f3ba1d9f..a996d12b 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -4,6 +4,7 @@ import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; import { TrafficTab } from "./TrafficTab"; +import { SignalTab } from "./SignalTab"; import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; @@ -12,7 +13,7 @@ import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "traffic", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -58,6 +59,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
{tab === "mesh" && } {tab === "traffic" && } + {tab === "signal" && } {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 0d76fad4..e2cff8b4 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -42,6 +42,12 @@ function ObserverIcon() { ); } +function SignalIcon() { + return + + ; +} + function TalkersIcon() { return ( @@ -76,6 +82,7 @@ function GraphIcon() { const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "traffic", label: "Traffic", icon: }, + { value: "signal", label: "RF / Signal", icon: }, { value: "scopes", label: "Scopes", icon: }, { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, diff --git a/src/features/stats/signal.ts b/src/features/stats/signal.ts new file mode 100644 index 00000000..e39c7137 --- /dev/null +++ b/src/features/stats/signal.ts @@ -0,0 +1,69 @@ +import type { EChartsOption } from "echarts"; +import type { SignalBin, SignalMetric, SignalStats } from "./types"; +import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; + +const HOUR = 3_600_000; +export function signalHours(data: SignalStats | undefined) { + if (!data || data.until <= data.since) return []; + const first = Math.floor(data.since / HOUR) * HOUR; + const rows = new Map(data.hourly.map((row) => [row.hour, row])); + return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => { + const hour = first + i * HOUR, row = rows.get(hour); + return { + hour, receptions: row?.receptions ?? null, + snrAverage: row && row.snrSamples > 0 ? row.snrAverage : null, + rssiAverage: row && row.rssiSamples > 0 ? row.rssiAverage : null, + }; + }); +} + +export function signalBinLabel(bin: SignalBin) { + if (bin.lower === null) return `< ${bin.upper}`; + if (bin.upper === null) return `≥ ${bin.lower}`; + return `${bin.lower} to < ${bin.upper}`; +} + +export function signalHistogramOption(metric: SignalMetric | undefined, name: string, unit: string, c: ChartColors): EChartsOption { + const bins = metric?.histogram ?? []; + return { + animation: false, + aria: { enabled: true, label: { description: `${name} distribution in ${unit}. Exact bin boundaries and counts follow below.` } }, + grid: { left: 8, right: 10, top: 20, bottom: 16, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: `${name} samples`, type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], + }; +} + +export function signalTrendOption(hours: ReturnType, metric: "snr" | "rssi", c: ChartColors): EChartsOption { + const color = metric === "snr" ? c.secondary : c.green; + const unit = metric === "snr" ? "dB" : "dBm"; + return { + animation: false, useUTC: true, + aria: { enabled: true, label: { description: `Hourly mean ${metric.toUpperCase()} in ${unit}, UTC. Missing readings remain gaps. Exact values are in the hourly table.` } }, + grid: { left: 8, right: 18, top: 20, bottom: 16, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c), valueFormatter: (value) => typeof value === "number" ? `${value.toFixed(2)} ${unit}` : "No sample" }, + xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", scale: true, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: `${metric.toUpperCase()} · ${unit}`, type: "line", connectNulls: false, showSymbol: true, symbolSize: 5, + lineStyle: { width: 2, color }, itemStyle: { color }, areaStyle: { color: withAlpha(color, 0.08) }, + data: hours.map((hour) => [hour.hour, hour[metric === "snr" ? "snrAverage" : "rssiAverage"]]) }], + }; +} + +export function signalCoverageOption(data: SignalStats | undefined, c: ChartColors): EChartsOption { + return { + animation: false, + aria: { enabled: true, label: { description: "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table." } }, + grid: { left: 8, right: 12, top: 30, bottom: 12, containLabel: true }, + legend: { top: 0, textStyle: { color: c.textMuted } }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + yAxis: { type: "category", data: ["SNR", "RSSI"], axisLabel: { color: c.textNormal }, axisLine: { show: false }, axisTick: { show: false } }, + series: [ + { name: "Available", type: "bar", stack: "samples", barMaxWidth: 22, itemStyle: { color: c.primary }, data: [data?.snr.samples ?? 0, data?.rssi.samples ?? 0] }, + { name: "Unavailable", type: "bar", stack: "samples", itemStyle: { color: c.warn }, data: [data ? data.receptions - data.snr.samples : 0, data ? data.receptions - data.rssi.samples : 0] }, + ], + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index d33cce69..66c6706f 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -153,7 +153,7 @@ export interface ObserverActivity { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "traffic" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; +export type StatsTab = "mesh" | "traffic" | "signal" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { @@ -161,3 +161,23 @@ export const RANGE_MS: Record = { "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; + +// beacon-server /stats/signal: retained observation rows in [since, until). +export interface SignalBin { lower: number | null; upper: number | null; count: number } +export interface SignalMetric { samples: number; average: number | null; histogram: SignalBin[] } +export interface SignalHour { + hour: number; + receptions: number; + snrSamples: number; + snrAverage: number | null; + rssiSamples: number; + rssiAverage: number | null; +} +export interface SignalStats { + since: number; + until: number; + receptions: number; + snr: SignalMetric; + rssi: SignalMetric; + hourly: SignalHour[]; +} diff --git a/src/features/stats/useSignalStats.ts b/src/features/stats/useSignalStats.ts new file mode 100644 index 00000000..604d26fb --- /dev/null +++ b/src/features/stats/useSignalStats.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { getSignalStats } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { RANGE_MS, type StatsRange } from "./types"; + +export function useSignalStats(range: StatsRange) { + const { iatas, regionKey, isResolved } = useRegion(); + return useQuery({ + queryKey: ["stats-signal", isResolved === false ? `${regionKey}:pending` : regionKey, range], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. + const until = Math.floor(Date.now() / 60_000) * 60_000; + return getSignalStats(until - RANGE_MS[range], until, iatas, signal); + }, + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts index b733ab0d..ddd5dfd2 100644 --- a/tests/api/analytics-client.test.ts +++ b/tests/api/analytics-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getStatsObservations, getStatsScopes } from "../../src/api/client"; +import { getStatsObservations, getStatsScopes, getSignalStats } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); @@ -18,3 +18,14 @@ it("forwards region and cancellation to both aggregate endpoints, omitting an em expect(url.searchParams.get("since")).toBe("1234"); expect(url.searchParams.get("iatas")).toBe("YOW"); }); + +it("sends explicit signal window bounds, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getSignalStats(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/signal"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); diff --git a/tests/features/stats/SignalTab.test.tsx b/tests/features/stats/SignalTab.test.tsx new file mode 100644 index 00000000..a27c7f89 --- /dev/null +++ b/tests/features/stats/SignalTab.test.tsx @@ -0,0 +1,29 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { SignalTab } from "../../../src/features/stats/SignalTab"; +import { useSignalStats } from "../../../src/features/stats/useSignalStats"; + +const query = { data: { since: 0, until: 3600000, receptions: 100, snr: { samples: 80, average: 0, histogram: [{ lower: -5, upper: 0, count: 80 }] }, rssi: { samples: 90, average: -102.5, histogram: [{ lower: -110, upper: -100, count: 90 }] }, hourly: [{ hour: 0, receptions: 100, snrSamples: 80, snrAverage: 0, rssiSamples: 90, rssiAverage: -102.5 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +vi.mock("../../../src/features/stats/useSignalStats", () => ({ useSignalStats: vi.fn(() => query) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +beforeEach(() => { query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("shows measured units, exact sample coverage and refresh without treating zero SNR as missing", () => { + render(); + expect(useSignalStats).toHaveBeenCalledWith("24h"); + expect(screen.getByText("0.0 dB")).toBeInTheDocument(); + expect(screen.getByText("-102.5 dBm")).toBeInTheDocument(); + expect(screen.getByText(/last hop/i)).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Signal sample availability" }); + expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /RSSI.*90.*10.*90.0%/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh signal" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); +it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides previous filter values when %s", (state) => { + query[state] = true; + render(); + expect(screen.queryByText("-102.5 dBm")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 48b334bd..7a443eaf 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -5,10 +5,20 @@ import { StatsOverview } from "../../../src/features/stats/StatsOverview"; import type { WsManager } from "../../../src/api/ws-manager"; vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) =>

Traffic range {range}

})); +vi.mock("../../../src/features/stats/SignalTab", () => ({ SignalTab: ({ range }: { range: string }) =>

Signal range {range}

})); vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh charts

})); vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } +it("opens Signal from a shared URL and retains regional state when changing range", () => { + render(); + expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "7d" })); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); + it("opens Traffic from a shared URL and keeps unrelated selections when changing the range", () => { render(); expect(screen.getByText("Traffic range 24h")).toBeInTheDocument(); diff --git a/tests/features/stats/signal-query.test.tsx b/tests/features/stats/signal-query.test.tsx new file mode 100644 index 00000000..cf443a2c --- /dev/null +++ b/tests/features/stats/signal-query.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { useSignalStats } from "../../../src/features/stats/useSignalStats"; +import { getSignalStats } from "../../../src/api/client"; +import type { StatsRange } from "../../../src/features/stats/types"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getSignalStats: vi.fn(() => new Promise(() => {})) })); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, rerender, unmount } = renderHook(({ range }: { range: StatsRange }) => useSignalStats(range), { initialProps: { range: "24h" }, wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getSignalStats).not.toHaveBeenCalled(); + region.isResolved = true; rerender({ range: "24h" }); + await waitFor(() => expect(getSignalStats).toHaveBeenCalledOnce()); + const first = vi.mocked(getSignalStats).mock.calls[0]!; + expect(first[1] % 60_000).toBe(0); + expect(first[1] - first[0]).toBe(24 * 3_600_000); + expect(first[2]).toEqual(["YVR"]); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender({ range: "30d" }); + await waitFor(() => expect(getSignalStats).toHaveBeenCalledTimes(2)); + expect(first[3]?.aborted).toBe(true); + const second = vi.mocked(getSignalStats).mock.calls[1]!; + expect(second[1] - second[0]).toBe(30 * 24 * 3_600_000); + expect(second[2]).toEqual(["YOW"]); + unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); +}); diff --git a/tests/features/stats/signal.test.ts b/tests/features/stats/signal.test.ts new file mode 100644 index 00000000..0cb27ec8 --- /dev/null +++ b/tests/features/stats/signal.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { signalHours, signalBinLabel, signalHistogramOption, signalTrendOption } from "../../../src/features/stats/signal"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { SignalStats } from "../../../src/features/stats/types"; + +const hour = 3_600_000; +export const signalFixture: SignalStats = { + since: hour / 2, until: 3 * hour + hour / 2, receptions: 100, + snr: { samples: 80, average: 0, histogram: [{ lower: null, upper: -30, count: 2 }, { lower: -30, upper: -25, count: 78 }, { lower: 30, upper: null, count: 0 }] }, + rssi: { samples: 90, average: -102.5, histogram: [{ lower: -110, upper: -100, count: 90 }] }, + hourly: [ + { hour: 0, receptions: 50, snrSamples: 50, snrAverage: 0, rssiSamples: 50, rssiAverage: -100 }, + { hour: 2 * hour, receptions: 40, snrSamples: 30, snrAverage: 0, rssiSamples: 40, rssiAverage: -105.625 }, + { hour: 3 * hour, receptions: 10, snrSamples: 0, snrAverage: null, rssiSamples: 0, rssiAverage: null }, + ], +}; + +describe("signal analytics", () => { + it("keeps UTC window edges, true zeros, missing hours and missing readings distinct", () => { + const before = JSON.stringify(signalFixture); + const hours = signalHours(signalFixture); + expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour, 3 * hour]); + expect(hours.map((h) => h.snrAverage)).toEqual([0, null, 0, null]); + expect(hours.map((h) => h.receptions)).toEqual([50, null, 40, 10]); + expect(JSON.stringify(signalFixture)).toBe(before); + expect(signalHours(undefined)).toEqual([]); + expect(signalHours({ ...signalFixture, until: 30 * 24 * hour + hour / 2, hourly: [] })).toHaveLength(721); + }); + it("labels exact half-open bins including overflow without assigning quality ratings", () => { + expect(signalBinLabel({ lower: null, upper: -30, count: 2 })).toBe("< -30"); + expect(signalBinLabel({ lower: -30, upper: -25, count: 2 })).toBe("-30 to < -25"); + expect(signalBinLabel({ lower: 30, upper: null, count: 2 })).toBe("≥ 30"); + const colors = readChartColors(); + expect(signalHistogramOption(signalFixture.snr, "SNR", "dB", colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); + expect(signalTrendOption(signalHours(signalFixture), "snr", colors)).toMatchObject({ animation: false, series: [{ connectNulls: false, showSymbol: true, data: [[0, 0], [hour, null], [2 * hour, 0], [3 * hour, null]] }] }); + }); +}); From 929ba83cf15bbbb89d8d0e4e1c0720efa6065ce5 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 23 Sep 2026 20:16:23 -0400 Subject: [PATCH 107/163] feat(stats): add Paths and Hashes analytics (#57) --- src/api/client.ts | 5 ++ src/features/stats/PathsTab.tsx | 75 +++++++++++++++++++ src/features/stats/StatsOverview.tsx | 4 +- src/features/stats/StatsSubHeader.tsx | 8 ++ src/features/stats/paths.ts | 46 ++++++++++++ src/features/stats/types.ts | 28 ++++++- src/features/stats/usePathStats.ts | 22 ++++++ tests/api/analytics-client.test.ts | 13 +++- tests/features/stats/PathsTab.test.tsx | 29 +++++++ .../stats/analytics-navigation.test.tsx | 10 +++ tests/features/stats/path-query.test.tsx | 33 ++++++++ tests/features/stats/paths.test.ts | 33 ++++++++ 12 files changed, 303 insertions(+), 3 deletions(-) create mode 100644 src/features/stats/PathsTab.tsx create mode 100644 src/features/stats/paths.ts create mode 100644 src/features/stats/usePathStats.ts create mode 100644 tests/features/stats/PathsTab.test.tsx create mode 100644 tests/features/stats/path-query.test.tsx create mode 100644 tests/features/stats/paths.test.ts diff --git a/src/api/client.ts b/src/api/client.ts index 5f39f321..da77179e 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -7,6 +7,7 @@ import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../featur import type { StatsOverview, SignalStats, + PathStats, ObserverComparison, ObservationPoint, PayloadBreakdownItem, @@ -314,6 +315,10 @@ export function getSignalStats(since: number, until: number, iatas?: string[], s return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal); } +export function getPathStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise { + return request("/stats/paths", { since, until, iatas: iatasParam(iatas) }, signal); +} + export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since }); } diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx new file mode 100644 index 00000000..d79b7595 --- /dev/null +++ b/src/features/stats/PathsTab.tsx @@ -0,0 +1,75 @@ +import { useMemo } from "react"; +import { formatCount } from "../../lib/formatters"; +import { Card, ChartCard, StatCard } from "./cards"; +import { donutOption } from "./chartOptions"; +import { tooltipStyle, useChartColors } from "./chartTheme"; +import { pathHours, pathLengthOption, pathTrendOption } from "./paths"; +import { usePathStats } from "./usePathStats"; +import type { StatsRange } from "./types"; + +const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); + +export function PathsTab({ range }: { range: StatsRange }) { + const query = usePathStats(range), c = useChartColors(); + const loading = query.isPending || query.isPlaceholderData; + const data = loading || query.isError ? undefined : query.data; + const hours = useMemo(() => pathHours(data), [data]); + const categories = useMemo(() => [ + { name: "Hash paths", value: data?.hashed ?? 0, color: c.primary }, + { name: "Empty", value: data?.empty ?? 0, color: c.textDim }, + { name: "Trace", value: data?.trace ?? 0, color: c.warn }, + { name: "Unclassified", value: data?.unclassified ?? 0, color: c.secondary }, + ], [data, c]); + const charts = useMemo(() => { + const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: `${bin.bytes}-byte`, value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), "HASH PATHS"); + const coverage = donutOption(categories, c, formatCount(data?.receptions ?? 0), "RECEPTIONS"); + return { + width: { ...width, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below." } } }, + coverage: { ...coverage, tooltip: { trigger: "item" as const, renderMode: "richText" as const, formatter: "{b}: {c} ({d}%)", ...tooltipStyle(c) }, aria: { enabled: true, label: { description: "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside." } } }, + lengths: pathLengthOption(data?.pathLengths ?? [], c), trend: pathTrendOption(hours, c), + }; + }, [data, c, categories, hours]); + const state = { isLoading: loading, isError: query.isError }; + const multi = data?.hashWidths.filter((bin) => bin.bytes > 1).reduce((n, bin) => n + bin.receptions, 0) ?? 0; + const largest = data?.pathLengths.at(-1)?.entries; + + return
+
+

Paths & Hashes

How received packets carry route hashes.

+ +
+ {query.isError &&

Could not load path data. Try refreshing or choosing a shorter time period.

} +
+ + + + +
+

Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.

+ {data &&

Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.

} +
+ + +
+ +
+ + + {!data ?

{query.isError ? "Data unavailable" : "Loading paths…"}

: !data.receptions ?

No retained receptions in this window.

: + + {categories.map((item) => )} +
CategoryReceptionsShare
{item.name}{item.value.toLocaleString()}{(100 * item.value / data.receptions).toFixed(1)}%
} +

Flood paths accumulate entries; direct routes contain remaining entries. Empty paths have no hash-width vote. Trace headers carry signal readings. Unclassified records lack usable payload or path metadata.

+
+
+ {data && data.receptions > 0 &&
+ Exact width, path-length and hourly values +
+ {data.hashWidths.map((bin) => )}
Bytes per hashReceptions
{bin.bytes}{bin.receptions.toLocaleString()}
+
{data.pathLengths.map((bin) => )}
Header entriesReceptions
{bin.entries}{bin.receptions.toLocaleString()}
+
+

Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.

+
{["UTC hour", "Receptions", "1-byte", "2-byte", "3-byte", "Empty", "Trace", "Unclassified"].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{utc(row.hour)}{n.toLocaleString()}
+
} +
; +} diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index a996d12b..aa920f98 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -5,6 +5,7 @@ import { StatsSubHeader } from "./StatsSubHeader"; import { MeshTab } from "./MeshTab"; import { TrafficTab } from "./TrafficTab"; import { SignalTab } from "./SignalTab"; +import { PathsTab } from "./PathsTab"; import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; @@ -13,7 +14,7 @@ import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "traffic", "signal", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -60,6 +61,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { {tab === "mesh" && } {tab === "traffic" && } {tab === "signal" && } + {tab === "paths" && } {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index e2cff8b4..9f188623 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -66,6 +66,13 @@ function ClockDriftIcon() { ); } +function PathsIcon() { + return + + + ; +} + function GraphIcon() { return ( @@ -83,6 +90,7 @@ const TAB_OPTIONS = [ { value: "mesh", label: "Mesh", icon: }, { value: "traffic", label: "Traffic", icon: }, { value: "signal", label: "RF / Signal", icon: }, + { value: "paths", label: "Paths & Hashes", icon: }, { value: "scopes", label: "Scopes", icon: }, { value: "talkers", label: "Talkers", icon: }, { value: "clockdrift", label: "Clock Drift", icon: }, diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts new file mode 100644 index 00000000..6425d080 --- /dev/null +++ b/src/features/stats/paths.ts @@ -0,0 +1,46 @@ +import type { EChartsOption } from "echarts"; +import type { PathLengthBin, PathStats } from "./types"; +import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; + +const HOUR = 3_600_000; +export function pathHours(data: PathStats | undefined) { + if (!data || data.until <= data.since) return []; + const first = Math.floor(data.since / HOUR) * HOUR; + const rows = new Map(data.hourly.map((row) => [row.hour, row])); + return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => { + const hour = first + i * HOUR, row = rows.get(hour); + return { hour, oneByte: row?.oneByte ?? null, twoByte: row?.twoByte ?? null, threeByte: row?.threeByte ?? null }; + }); +} + +export function pathLengthOption(bins: PathLengthBin[], c: ChartColors): EChartsOption { + const max = Math.min(63, Math.max(0, ...bins.map((bin) => bin.entries))); + const counts = new Map(bins.map((bin) => [bin.entries, bin.receptions])); + const entries = Array.from({ length: max + 1 }, (_, i) => i); + return { + animation: false, + aria: { enabled: true, label: { description: "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table." } }, + grid: { left: 8, right: 12, top: 18, bottom: 38, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "category", data: entries, name: "Path entries", nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: [{ name: "Receptions", type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], + }; +} + +export function pathTrendOption(hours: ReturnType, c: ChartColors): EChartsOption { + return { + animation: false, useUTC: true, + aria: { enabled: true, label: { description: "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table." } }, + legend: { top: 0, textStyle: { color: c.textNormal, fontSize: 10 }, itemWidth: 10, itemHeight: 10 }, + grid: { left: 8, right: 18, top: 35, bottom: 15, containLabel: true }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, + xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, + yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + series: (["oneByte", "twoByte", "threeByte"] as const).map((field, i) => ({ + name: `${i + 1}-byte`, type: "line", stack: "hash-paths", connectNulls: false, showSymbol: true, symbolSize: 4, + lineStyle: { width: 1.5, color: c.series[i] }, itemStyle: { color: c.series[i] }, areaStyle: { color: withAlpha(c.series[i]!, 0.32) }, + data: hours.map((hour) => [hour.hour, hour[field]]), + })), + }; +} diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 66c6706f..07505185 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -153,7 +153,7 @@ export interface ObserverActivity { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "traffic" | "signal" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; +export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { @@ -181,3 +181,29 @@ export interface SignalStats { rssi: SignalMetric; hourly: SignalHour[]; } + +// beacon-server /stats/paths: retained receptions; the four categories partition the total. +export interface PathHashWidth { bytes: number; receptions: number } +export interface PathLengthBin { entries: number; receptions: number } +export interface PathHour { + hour: number; + receptions: number; + oneByte: number; + twoByte: number; + threeByte: number; + empty: number; + trace: number; + unclassified: number; +} +export interface PathStats { + since: number; + until: number; + receptions: number; + hashed: number; + empty: number; + trace: number; + unclassified: number; + hashWidths: PathHashWidth[]; + pathLengths: PathLengthBin[]; + hourly: PathHour[]; +} diff --git a/src/features/stats/usePathStats.ts b/src/features/stats/usePathStats.ts new file mode 100644 index 00000000..6a9668cf --- /dev/null +++ b/src/features/stats/usePathStats.ts @@ -0,0 +1,22 @@ +import { useQuery } from "@tanstack/react-query"; +import { getPathStats } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; +import { RANGE_MS, type StatsRange } from "./types"; + +export function usePathStats(range: StatsRange) { + const { iatas, regionKey, isResolved } = useRegion(); + return useQuery({ + queryKey: ["stats-paths", isResolved === false ? `${regionKey}:pending` : regionKey, range], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. + const until = Math.floor(Date.now() / 60_000) * 60_000; + return getPathStats(until - RANGE_MS[range], until, iatas, signal); + }, + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + retry: false, + }); +} diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts index ddd5dfd2..2684a2dc 100644 --- a/tests/api/analytics-client.test.ts +++ b/tests/api/analytics-client.test.ts @@ -1,8 +1,19 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getStatsObservations, getStatsScopes, getSignalStats } from "../../src/api/client"; +import { getStatsObservations, getStatsScopes, getSignalStats, getPathStats } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); +it("sends path window bounds, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getPathStats(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/paths"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); + it("forwards region and cancellation to both aggregate endpoints, omitting an empty global filter", async () => { const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); vi.stubGlobal("fetch", fetcher); diff --git a/tests/features/stats/PathsTab.test.tsx b/tests/features/stats/PathsTab.test.tsx new file mode 100644 index 00000000..da0091e6 --- /dev/null +++ b/tests/features/stats/PathsTab.test.tsx @@ -0,0 +1,29 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; +import { PathsTab } from "../../../src/features/stats/PathsTab"; +import { usePathStats } from "../../../src/features/stats/usePathStats"; + +const query = { data: { since: 0, until: 3600000, receptions: 100, hashed: 60, empty: 20, trace: 10, unclassified: 10, + hashWidths: [{ bytes: 1, receptions: 20 }, { bytes: 2, receptions: 30 }, { bytes: 3, receptions: 10 }], pathLengths: [{ entries: 0, receptions: 20 }, { entries: 2, receptions: 60 }], + hourly: [{ hour: 0, receptions: 100, oneByte: 20, twoByte: 30, threeByte: 10, empty: 20, trace: 10, unclassified: 10 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +vi.mock("../../../src/features/stats/usePathStats", () => ({ usePathStats: vi.fn(() => query) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +beforeEach(() => { query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("uses only nonempty hash paths for multi-byte share, exposes categories and explains remaining routes", () => { + render(); + expect(usePathStats).toHaveBeenCalledWith("24h"); + expect(screen.getByText("66.7%")).toBeInTheDocument(); + expect(screen.getByText(/direct routes/i)).toHaveTextContent("remaining"); + const table = screen.getByRole("table", { name: "Path classification counts" }); + expect(within(table).getByRole("row", { name: /Hash paths.*60.*60.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Empty.*20.*20.0%/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh paths" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); +it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old values when %s", (state) => { + query[state] = true; render(); + expect(screen.queryByText("66.7%")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); +}); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 7a443eaf..2a523c2d 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -6,10 +6,20 @@ import type { WsManager } from "../../../src/api/ws-manager"; vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) =>

Traffic range {range}

})); vi.mock("../../../src/features/stats/SignalTab", () => ({ SignalTab: ({ range }: { range: string }) =>

Signal range {range}

})); +vi.mock("../../../src/features/stats/PathsTab", () => ({ PathsTab: ({ range }: { range: string }) =>

Paths range {range}

})); vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh charts

})); vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } +it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { + render(); + expect(screen.getByText("Paths range 24h")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Paths & Hashes" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(screen.getByRole("button", { name: "30d" })); + expect(screen.getByText("Paths range 30d")).toBeInTheDocument(); + expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); +}); + it("opens Signal from a shared URL and retains regional state when changing range", () => { render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); diff --git a/tests/features/stats/path-query.test.tsx b/tests/features/stats/path-query.test.tsx new file mode 100644 index 00000000..d0071c64 --- /dev/null +++ b/tests/features/stats/path-query.test.tsx @@ -0,0 +1,33 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { renderHook, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { usePathStats } from "../../../src/features/stats/usePathStats"; +import { getPathStats } from "../../../src/api/client"; +import type { StatsRange } from "../../../src/features/stats/types"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getPathStats: vi.fn(() => new Promise(() => {})) })); +afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); + +it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { + region.isResolved = false; + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { result, rerender, unmount } = renderHook(({ range }: { range: StatsRange }) => usePathStats(range), { initialProps: { range: "24h" }, wrapper: ({ children }: { children: ReactNode }) => {children} }); + expect(result.current.isPending).toBe(true); + expect(getPathStats).not.toHaveBeenCalled(); + region.isResolved = true; rerender({ range: "24h" }); + await waitFor(() => expect(getPathStats).toHaveBeenCalledOnce()); + const first = vi.mocked(getPathStats).mock.calls[0]!; + expect(first[1] % 60_000).toBe(0); + expect(first[1] - first[0]).toBe(24 * 3_600_000); + expect(first[2]).toEqual(["YVR"]); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender({ range: "30d" }); + await waitFor(() => expect(getPathStats).toHaveBeenCalledTimes(2)); + expect(first[3]?.aborted).toBe(true); + const second = vi.mocked(getPathStats).mock.calls[1]!; + expect(second[1] - second[0]).toBe(30 * 24 * 3_600_000); + expect(second[2]).toEqual(["YOW"]); + unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); +}); diff --git a/tests/features/stats/paths.test.ts b/tests/features/stats/paths.test.ts new file mode 100644 index 00000000..3dcbe611 --- /dev/null +++ b/tests/features/stats/paths.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { pathHours, pathLengthOption, pathTrendOption } from "../../../src/features/stats/paths"; +import { readChartColors } from "../../../src/features/stats/chartTheme"; +import type { PathStats } from "../../../src/features/stats/types"; + +const hour = 3_600_000; +const fixture: PathStats = { since: hour / 2, until: 3 * hour, receptions: 10, hashed: 6, empty: 2, trace: 1, unclassified: 1, + hashWidths: [{ bytes: 1, receptions: 2 }, { bytes: 2, receptions: 3 }, { bytes: 3, receptions: 1 }], + pathLengths: [{ entries: 0, receptions: 2 }, { entries: 2, receptions: 5 }, { entries: 63, receptions: 1 }], + hourly: [{ hour: 0, receptions: 8, oneByte: 2, twoByte: 3, threeByte: 1, empty: 1, trace: 1, unclassified: 0 }, { hour: 2 * hour, receptions: 2, oneByte: 0, twoByte: 0, threeByte: 0, empty: 1, trace: 0, unclassified: 1 }] }; + +describe("path analytics charts", () => { + it("keeps absent hours distinct from known zero hash-path hours and preserves response values", () => { + const before = JSON.stringify(fixture), hours = pathHours(fixture); + expect(hours.map((h) => h.hour)).toEqual([0, hour, 2 * hour]); + expect(hours.map((h) => h.oneByte)).toEqual([2, null, 0]); + expect(hours.map((h) => h.twoByte)).toEqual([3, null, 0]); + expect(JSON.stringify(fixture)).toBe(before); + expect(pathHours(undefined)).toEqual([]); + expect(pathHours({ ...fixture, until: 30 * 24 * hour + hour / 2 })).toHaveLength(721); + }); + it("fills zero-count length bins while preserving empty-path counts and visible single-hour points", () => { + const c = readChartColors(); + const length = pathLengthOption(fixture.pathLengths, c); + expect(length).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); + const option = length as { xAxis: { data: number[] }; series: { data: { value: number }[] }[] }; + expect(option.xAxis.data).toHaveLength(64); + expect(option.series[0]!.data[0]!.value).toBe(2); + expect(option.series[0]!.data[1]!.value).toBe(0); + expect(option.series[0]!.data.reduce((n, d) => n + d.value, 0)).toBe(8); + expect(pathTrendOption(pathHours(fixture), c)).toMatchObject({ animation: false, useUTC: true, legend: { top: 0 }, series: [{ connectNulls: false, showSymbol: true, data: [[0, 2], [hour, null], [2 * hour, 0]] }, { data: [[0, 3], [hour, null], [2 * hour, 0]] }, { data: [[0, 1], [hour, null], [2 * hour, 0]] }] }); + }); +}); From 0f0a6ca51c7b2c3315db77954f61b30bbdeea5e2 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 23 Sep 2026 20:16:26 -0400 Subject: [PATCH 108/163] fix(map): omit reset and invalid node locations (#61) --- src/features/map/location.ts | 11 +++++++++ src/features/map/node-geojson.ts | 14 ++++++------ src/features/map/packet-flow.ts | 3 ++- src/features/map/packet-path.ts | 3 ++- tests/features/map/node-geojson.test.ts | 30 +++++++++++++++++++++---- tests/features/map/packet-flow.test.ts | 5 +++++ tests/features/map/packet-path.test.ts | 11 +++++++++ 7 files changed, 64 insertions(+), 13 deletions(-) create mode 100644 src/features/map/location.ts diff --git a/src/features/map/location.ts b/src/features/map/location.ts new file mode 100644 index 00000000..beaa0a9a --- /dev/null +++ b/src/features/map/location.ts @@ -0,0 +1,11 @@ +// Beacon preserves explicit 0/0 advert resets in the data. They are unlocated +// radio nodes, not markers at Null Island. Either axis alone may validly be zero. +// This is a node-location rule, not a restriction on map camera coordinates. +export function hasMapLocation( + node: { lat?: number | null; lng?: number | null } | null | undefined, +): node is { lat: number; lng: number } { + return node?.lat != null && node.lng != null + && Number.isFinite(node.lat) && Number.isFinite(node.lng) + && Math.abs(node.lat) <= 90 && Math.abs(node.lng) <= 180 + && (node.lat !== 0 || node.lng !== 0); +} diff --git a/src/features/map/node-geojson.ts b/src/features/map/node-geojson.ts index e6452d66..65609b6b 100644 --- a/src/features/map/node-geojson.ts +++ b/src/features/map/node-geojson.ts @@ -1,5 +1,6 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { NodeSummary, NodeNeighbor } from "../nodes/types"; +import { hasMapLocation } from "./location"; // Build the maplibre GeoJSON source from the nodes API response. Properties stay primitive because // clustering serializes them, and there's no maplibre import, so this stays unit-testable. @@ -16,8 +17,7 @@ export function nodesToFeatureCollection( ): FeatureCollection { const features: Feature[] = []; for (const n of nodes) { - // != null keeps 0 (a valid coordinate) while dropping null/undefined - if (n.lat == null || n.lng == null) continue; + if (!hasMapLocation(n)) continue; features.push({ type: "Feature", // GeoJSON/maplibre order is [lng, lat]; the API sends decimal degrees as-is @@ -52,13 +52,13 @@ export function buildNeighborEdges( ): FeatureCollection { const located = new Map(); for (const n of nodes) { - if (n.lat != null && n.lng != null) located.set(n.id, n); + if (hasMapLocation(n)) located.set(n.id, n); } const seen = new Set(); const features: Feature[] = []; for (const n of nodes) { - if (n.lat == null || n.lng == null || !n.neighborIds) continue; + if (!hasMapLocation(n) || !n.neighborIds) continue; for (const otherId of n.neighborIds) { if (otherId === n.id) continue; // a node listing itself would draw a zero-length edge const other = located.get(otherId); @@ -89,12 +89,12 @@ export function buildFocusedNeighborEdges( now: number = Date.now(), ): FeatureCollection { const empty: FeatureCollection = { type: "FeatureCollection", features: [] }; - if (!selected || selected.lat == null || selected.lng == null) return empty; + if (!hasMapLocation(selected)) return empty; const from: [number, number] = [selected.lng, selected.lat]; const byId = new Map(); for (const nb of neighbors) { - if (nb.id === selected.id || nb.lat == null || nb.lng == null) continue; + if (nb.id === selected.id || !hasMapLocation(nb)) continue; const prev = byId.get(nb.id); if (prev) { prev.obs += nb.observationCount; @@ -123,7 +123,7 @@ export function neighborFocusIds(nodes: NodeSummary[], selectedId: string | null if (!selectedId) return null; const located = new Map(); for (const n of nodes) { - if (n.lat != null && n.lng != null) located.set(n.id, n); + if (hasMapLocation(n)) located.set(n.id, n); } const selected = located.get(selectedId); if (!selected) return null; diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index cce46576..a4384dfa 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -1,4 +1,5 @@ import type { ResolvedHop } from "../../types/api"; +import { hasMapLocation } from "./location"; // Pure helpers for drawing a packet's path — the live flow animation (modelled on MeshMapper's // LiveViz) and the path map share them. No maplibre import, so they stay unit-testable; the hook @@ -24,7 +25,7 @@ export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; ln const seen = new Set(); const out: { id: string; lng: number; lat: number }[] = []; for (const hop of resolvedPath) { - const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null); + const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude })); if (node && !seen.has(node.id)) { seen.add(node.id); out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! }); diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index b0d5553b..2b397759 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -2,6 +2,7 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; import { PayloadType } from "../../types/enums"; import { packetChain } from "./packet-flow"; +import { hasMapLocation } from "./location"; export interface PathPoint { id: string; @@ -37,7 +38,7 @@ function pathPoints(hops: ResolvedHop[]): PathPoint[] { const seen = new Set(); const out: PathPoint[] = []; for (const hop of hops) { - const node = hop.nodes.find((n) => n.latitude != null && n.longitude != null); + const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude })); if (node && !seen.has(node.id)) { seen.add(node.id); out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude! }); diff --git a/tests/features/map/node-geojson.test.ts b/tests/features/map/node-geojson.test.ts index 79a7de4a..23fb28da 100644 --- a/tests/features/map/node-geojson.test.ts +++ b/tests/features/map/node-geojson.test.ts @@ -64,10 +64,15 @@ describe("nodesToFeatureCollection", () => { expect(fc.features.map((f) => f.properties.id)).toEqual(["c"]); }); - it("keeps nodes at the 0/0 coordinate (0 is a valid coordinate)", () => { - const fc = nodesToFeatureCollection([node({ id: "z", lat: 0, lng: 0 })]); - expect(fc.features).toHaveLength(1); - expect(fc.features[0]!.geometry.coordinates).toEqual([0, 0]); + it("omits location resets and unusable coordinates", () => { + const positions = [[0, 0], [-0, 0], [NaN, 10], [10, Infinity], [91, 20], [-91, 20], [10, 181], [10, -181]]; + expect(nodesToFeatureCollection(positions.map(([lat, lng], i) => node({ id: String(i), lat, lng }))).features).toEqual([]); + }); + + it("keeps valid equator, prime-meridian and boundary locations", () => { + const positions = [[0, -75], [45, 0], [90, 180], [-90, -180]]; + const fc = nodesToFeatureCollection(positions.map(([lat, lng], i) => node({ id: String(i), lat, lng }))); + expect(fc.features.map((f) => f.geometry.coordinates)).toEqual([[-75, 0], [0, 45], [180, 90], [-180, -90]]); }); it("passes decimal coordinates through untouched (api/nodes.go sends *float64 degrees)", () => { @@ -114,6 +119,17 @@ describe("buildNeighborEdges", () => { const fc = buildNeighborEdges([selfRef, b], "on", null); expect(fc.features).toHaveLength(1); // a<->b only, not a<->a }); + + it("omits both directions of neighbour edges to reset or invalid locations", () => { + const valid = node({ id: "a", neighborIds: ["reset", "invalid"] }); + const reset = node({ id: "reset", lat: 0, lng: 0, neighborIds: ["a"] }); + const invalid = node({ id: "invalid", lat: 91, lng: 10, neighborIds: ["a"] }); + for (const mode of ["on", "selected"] as const) { + expect(buildNeighborEdges([valid, reset, invalid], mode, "a").features).toEqual([]); + } + expect(neighborFocusIds([valid, reset, invalid], "a")).toBeNull(); + expect(neighborFocusIds([valid, reset, invalid], "reset")).toBeNull(); + }); }); describe("neighborFocusIds", () => { @@ -200,6 +216,12 @@ describe("buildFocusedNeighborEdges", () => { ], NOW); expect(fc.features.map((f) => f.properties.obs)).toEqual([5]); }); + + it("omits reset and invalid locations from focused edges", () => { + const candidates = [nb({ id: "reset", lat: 0, lng: 0 }), nb({ id: "invalid", lat: 10, lng: Infinity }), nb({ id: "valid", lat: 0, lng: 15 })]; + expect(buildFocusedNeighborEdges(sel, candidates, NOW).features.map((f) => f.geometry.coordinates)).toEqual([[[-75, 45], [15, 0]]]); + expect(buildFocusedNeighborEdges(node({ lat: 0, lng: 0 }), [nb({})], NOW).features).toEqual([]); + }); }); describe("filterByNodeType", () => { diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index e3906578..ae6df1a6 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -35,6 +35,11 @@ describe("resolvedPathNodes", () => { it("skips hops with no located candidate", () => { expect(resolvedPathNodes([{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }])).toEqual([]); }); + + it("skips reset/invalid candidates and still finds a usable candidate", () => { + const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 10, 91).nodes, ...hop("valid", 0, 45).nodes] }; + expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]); + }); }); describe("posAtHop", () => { diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 1c783859..86b6514c 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -21,6 +21,17 @@ function detail(observations: Observation[], over: Partial = {}): } describe("buildPacketPaths", () => { + it("excludes location resets and invalid candidates from observation and trace paths", () => { + const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 181, 10).nodes, ...hop("valid", 0, 45).nodes] }; + const hops = [hop("unknown", 0, 0), candidates, hop("end", -75, 0)]; + const ordinary = buildPacketPaths(detail([obs(1, hops)])); + const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: hops } as Partial)); + for (const paths of [ordinary, trace]) { + expect(paths[0]!.points.map((p) => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]); + } + expect(buildPacketPaths(detail([obs(1, [hop("unknown", 0, 0), hop("one", -75, 45)])]))).toEqual([]); + }); + it("keys each path by observerId and carries propagation, fastest first", () => { const d = detail([ obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-slow", observerName: "Slow", propagationTimeMs: 900 }), From 6d3edbb61bb79fe7aa336c5ae93a6c0a3f6f7545 Mon Sep 17 00:00:00 2001 From: n30nex Date: Thu, 24 Sep 2026 08:53:32 -0400 Subject: [PATCH 109/163] feat(i18n): add English and French navigation foundation (#62) --- CONTRIBUTING.md | 3 + docs/translations.md | 56 +++++++++++++++++ package-lock.json | 78 +++++++++++++++++++++++- package.json | 2 + src/App.tsx | 8 ++- src/components/AppShell.tsx | 52 ++++++++++------ src/components/BottomNav.tsx | 13 ++-- src/components/LanguagePicker.tsx | 67 ++++++++++++++++++++ src/i18n/index.ts | 52 ++++++++++++++++ src/i18n/locales/en.json | 44 +++++++++++++ src/i18n/locales/fr.json | 45 ++++++++++++++ src/main.tsx | 1 + tests/components/AppShell.test.tsx | 38 +++++++++++- tests/components/BottomNav.test.tsx | 15 ++++- tests/components/LanguagePicker.test.tsx | 42 +++++++++++++ tests/i18n/index.test.ts | 64 +++++++++++++++++++ tests/setup.ts | 4 +- 17 files changed, 552 insertions(+), 32 deletions(-) create mode 100644 docs/translations.md create mode 100644 src/components/LanguagePicker.tsx create mode 100644 src/i18n/index.ts create mode 100644 src/i18n/locales/en.json create mode 100644 src/i18n/locales/fr.json create mode 100644 tests/components/LanguagePicker.test.tsx create mode 100644 tests/i18n/index.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1c6163da..8d21955f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,6 +82,9 @@ because the root `tsconfig.json` has `files: []`. ## Tests +For UI text, follow the [translation guide](docs/translations.md). Add English +catalog keys and translate display labels without changing route/data identifiers. + - **Practice TDD for bugfixes and features** — write the failing test first, watch it fail, then implement. - Tests live in a top-level `tests/` tree that mirrors `src/` (e.g. diff --git a/docs/translations.md b/docs/translations.md new file mode 100644 index 00000000..cd06148b --- /dev/null +++ b/docs/translations.md @@ -0,0 +1,56 @@ +# Translating Beacon + +The header language picker starts in English and saves the selected language in +this browser. Its trigger and panel use the same dropdown styling as the region +control. Open it with Enter/Space, Tab to a language and activate that button; +Escape closes it and returns focus to the trigger. French is the first +additional language. This first slice covers +desktop/mobile navigation, the region and theme controls, connection/retry +labels and lazy-page loading text. Detailed feature pages, chart labels, general +dialogs and data formatting remain follow-up work; selecting French does not +mean every page is translated yet. + +## Add a language + +1. Copy `src/i18n/locales/en.json` to a language-tag filename such as `de.json`. +2. Set `name` to its native name, such as `Deutsch`. Translate string values + under `translation`; keep their keys and interpolation placeholders intact. +3. Build and test. Vite discovers every JSON catalog in that directory, and its + native name appears in the language picker automatically. No central language + list, plugin or translation server is needed. + +Missing or empty translations fall back to English. Catalogs are bundled with the +application, so a new language requires a web build/deployment. Keep English as +the complete source catalog. Every selectable language must have a native name +and at least one translated string. Use plain text, not HTML, in translations. + +## Add translated UI text + +```tsx +import { useTranslation } from "react-i18next"; + +function LoadingMessage() { + const { t } = useTranslation(); + return {t("common.loading")}; +} +``` + +Add the English key/value and translate it in other catalogs where possible. +Use `t("region.count", { count })` with i18next's language-specific plural +suffixes (`_one`, `_other`, and `_many` where applicable). Preserve placeholders +such as `{{seconds}}`; translate the whole phrase rather than joining words. +React escapes interpolated text; do not use `dangerouslySetInnerHTML` for it. + +Translate display labels, never the tab IDs (`Packets`, `Analytics`, etc.), URL +parameters, region slugs/IATA codes, query keys or WebSocket subscriptions. +Names and packet contents come from the network and remain unchanged. This slice +does not change date/time/number formatting or measurement units. HTML language +and direction follow the selected catalog; a future right-to-left translation +also needs layout review before being offered to users. + +Run `npm run build`, `npm run lint` and `npm test`. Test saved/unknown language +preferences, English fallback and switching without losing the selected view. +Check the picker and menus at narrow widths, including translated status text. +The language preference tolerates unavailable browser storage; the choice then +lasts only for that visit. Tests use the real i18next resources, reset to English +after each test, and keep route/state assertions on canonical identifiers. diff --git a/package-lock.json b/package-lock.json index a57c9149..ed18e9ae 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,9 +13,11 @@ "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", "echarts": "6.1.0", + "i18next": "26.4.2", "maplibre-gl": "^6.9.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "17.0.15", "react-router-dom": "^7.18.2" }, "devDependencies": { @@ -293,7 +295,6 @@ "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -2961,6 +2962,43 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/html-parse-stringify": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-4.0.1.tgz", + "integrity": "sha512-0zHsZJrK7S3K2aucXWL6ycoYJ/iNtIcFHC/nYQgFklPtrv5LpJctIiSCroWZWeuoXvuyFdzp6KzjJQ+OT5MfFw==", + "license": "MIT", + "funding": { + "url": "https://locize.com" + } + }, + "node_modules/i18next": { + "version": "26.4.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.4.2.tgz", + "integrity": "sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "peerDependencies": { + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -3851,6 +3889,33 @@ "react": "^19.2.7" } }, + "node_modules/react-i18next": { + "version": "17.0.15", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.15.tgz", + "integrity": "sha512-7uYpx/oBkesLto7cOzOmfITlUBaXHCAcFahmgETMfWr8nbbWRRnVGvz8wsH/xpbNcyoH+NpCcnOQEqWPMO6/hw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.7", + "html-parse-stringify": "^4.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.2.0", + "react": ">= 16.8.0", + "typescript": "^5 || ^6 || ^7" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -4225,7 +4290,7 @@ "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -4317,6 +4382,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz", + "integrity": "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.16", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", diff --git a/package.json b/package.json index 45276a2e..284061c9 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "@tanstack/react-query": "^5.100.11", "@tanstack/react-virtual": "^3.13.25", "echarts": "6.1.0", + "i18next": "26.4.2", "maplibre-gl": "^6.9.0", "react": "^19.2.6", "react-dom": "^19.2.6", + "react-i18next": "17.0.15", "react-router-dom": "^7.18.2" }, "devDependencies": { diff --git a/src/App.tsx b/src/App.tsx index 573822e1..28fcb796 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"; import { BrowserRouter, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion"; import { @@ -142,6 +143,11 @@ export function SelectionResetOnRegion({ onRegionChange }: { onRegionChange: () // tab state and region init +function TabLoading({ tab }: { tab: string }) { + const { t } = useTranslation(); + return ; +} + function AppInner() { const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); @@ -309,7 +315,7 @@ function AppInner() {
- }> + }> {tabContent[activeTab]}
diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index cc0d9e94..77450882 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -1,4 +1,6 @@ import { type ReactNode, useState, useEffect, useMemo, useRef } from "react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; import { ErrorBoundary } from "./ErrorBoundary"; import { useQuery } from "@tanstack/react-query"; import { useRegionSelection, useRegions } from "../hooks/useRegion"; @@ -8,6 +10,7 @@ import { useRateLimit } from "../hooks/useRateLimit"; import { useTheme } from "../hooks/useTheme"; import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; +import { LanguagePicker } from "./LanguagePicker"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants"; @@ -16,6 +19,7 @@ import type { WsManager } from "../api/ws-manager"; // header widgets: WS status, region picker, theme picker function LiveBadge({ wsManager }: { wsManager: WsManager }) { + const { t } = useTranslation(); const { status } = useWsStatus(wsManager); const [staleStr, setStaleStr] = useState(""); @@ -34,7 +38,7 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { return (
- LIVE + {t("connection.live")}
); } @@ -42,20 +46,21 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { if (status === "connecting") { return (
- STALE {staleStr} + {t("connection.stale", { age: staleStr })}
); } return (
- OFFLINE + {t("connection.offline")}
); } // Shown while the API is throttling us; the countdown tells users the blank tables are temporary. function RateLimitBadge() { + const { t } = useTranslation(); const until = useRateLimit(); const [remaining, setRemaining] = useState(0); @@ -74,7 +79,7 @@ function RateLimitBadge() { return (
- RATE LIMITED {remaining}s + {t("connection.rateLimited", { seconds: remaining })}
); } @@ -95,14 +100,14 @@ function CheckBox({ checked }: { checked: boolean }) { } // Compact header summary of the active selection, e.g. "ALL", "YVR, YYJ", "2 regions", "1 region · 3 IATA". -function regionSummaryLabel(selection: RegionSelection): string { - if (isAllRegions(selection)) return "ALL"; +function regionSummaryLabel(selection: RegionSelection, t: TFunction): string { + if (isAllRegions(selection)) return t("region.allShort"); const parts: string[] = []; if (selection.regions.length > 0) { - parts.push(`${selection.regions.length} region${selection.regions.length > 1 ? "s" : ""}`); + parts.push(t("region.count", { count: selection.regions.length })); } if (selection.iatas.length > 0) { - parts.push(selection.iatas.length <= 2 ? selection.iatas.join(", ") : `${selection.iatas.length} IATA`); + parts.push(selection.iatas.length <= 2 ? selection.iatas.join(", ") : t("region.iataCount", { count: selection.iatas.length })); } return parts.join(" · "); } @@ -110,6 +115,7 @@ function regionSummaryLabel(selection: RegionSelection): string { // Grouped multi-select: regions (each expands to its member IATAs) on top, then individual IATAs. // Toggling keeps the dropdown open so several can be picked; "All Regions" clears the selection. function RegionSelector() { + const { t } = useTranslation(); const { selection } = useRegionSelection(); return ( @@ -122,8 +128,8 @@ function RegionSelector() { className="flex items-center gap-1.5 bg-bg-raised border border-border rounded px-3 py-1 text-text-bright font-mono text-xs font-semibold hover:border-text-dim/30 transition-colors" onClick={toggle} > - REGION - {regionSummaryLabel(selection)} + {t("region.label")} + {regionSummaryLabel(selection, t)} ▾ )} @@ -135,6 +141,7 @@ function RegionSelector() { // Split out from RegionSelector so the filter query lives and dies with the open panel. function RegionSelectorPanel() { + const { t } = useTranslation(); const { selection, setSelection } = useRegionSelection(); const { regions } = useRegions(); const [query, setQuery] = useState(""); @@ -193,7 +200,7 @@ function RegionSelectorPanel() { ); }, [iatas, q]); - const showAll = !q || "all regions".includes(q); + const showAll = !q || t("region.allRegions").toLowerCase().includes(q); const showIataGroup = !iatas || shownIatas.length > 0; // keep the group while loading/failed const hasRowsAbove = showAll || shownRegions.length > 0; @@ -213,7 +220,8 @@ function RegionSelectorPanel() { setQuery(""); } }} - placeholder="Filter IATA or name…" + aria-label={t("region.filter")} + placeholder={t("region.filter")} className="w-full text-[16px] font-mono bg-bg-surface border border-border rounded px-2 py-1 text-text-bright placeholder:text-text-dim" />
@@ -230,14 +238,14 @@ function RegionSelectorPanel() { > {/* spacer matching the checkbox column so ALL/code/name align with the rows below */}
@@ -20,7 +21,7 @@ interface DataTableProps { onSelect: (key: string | null) => void; isLoading?: boolean; emptyLabel: string; - defaultSort?: { header: string; direction?: SortDirection }; + defaultSort?: ({ id: string } | { header: string }) & { direction?: SortDirection }; // called when the scroll position nears the bottom, for on-demand paging (omit = no infinite scroll) onEndReached?: () => void; // when set, rows render as stacked cards below the md breakpoint instead of a table; sort UI lives @@ -35,22 +36,22 @@ const END_REACHED_THRESHOLD_PX = 200; export function DataTable({ columns, rows, rowKey, selectedKey, onSelect, isLoading, emptyLabel, defaultSort, onEndReached, renderCard }: DataTableProps) { const asCards = useIsMobile() && !!renderCard; - const [sort, setSort] = useState<{ header: string; direction: SortDirection }>(() => ({ - header: defaultSort?.header ?? "", + const [sort, setSort] = useState<{ id: string; direction: SortDirection }>(() => ({ + id: defaultSort ? ("id" in defaultSort ? defaultSort.id : columns.find((col) => col.header === defaultSort.header)?.id ?? defaultSort.header) : "", direction: defaultSort?.direction ?? "asc", })); - function toggleSort(header: string) { + function toggleSort(id: string) { setSort((prev) => - prev.header === header - ? { header, direction: prev.direction === "asc" ? "desc" : "asc" } - : { header, direction: "asc" }, + prev.id === id + ? { id, direction: prev.direction === "asc" ? "desc" : "asc" } + : { id, direction: "asc" }, ); } const sortedRows = useMemo(() => { if (!rows) return rows; - const col = columns.find((c) => c.header === sort.header && c.sortValue); + const col = columns.find((c) => (c.id ?? c.header) === sort.id && c.sortValue); if (!col?.sortValue) return rows; const getValue = col.sortValue; const dir = sort.direction === "asc" ? 1 : -1; @@ -117,15 +118,16 @@ export function DataTable({ columns, rows, rowKey, selectedKey, onSelect, isL
{col.header}{col.header} + {col.cell(row)}{col.cell(row)}
- - {categories.map((item) => )} + + + {!data ?

{query.isError ? t("common.dataUnavailable") : t("paths.loading")}

: !data.receptions ?

{t("paths.empty")}

:
CategoryReceptionsShare
{item.name}{item.value.toLocaleString()}{(100 * item.value / data.receptions).toFixed(1)}%
+ + {categories.map((item) => )}
{t("paths.category")}{t("paths.receptionsColumn")}{t("paths.share")}
{item.name}{item.value.toLocaleString()}{(100 * item.value / data.receptions).toFixed(1)}%
} -

Flood paths accumulate entries; direct routes contain remaining entries. Empty paths have no hash-width vote. Trace headers carry signal readings. Unclassified records lack usable payload or path metadata.

+

{t("paths.countsHelp")}

{data && data.receptions > 0 &&
- Exact width, path-length and hourly values + {t("paths.details")}
- {data.hashWidths.map((bin) => )}
Bytes per hashReceptions
{bin.bytes}{bin.receptions.toLocaleString()}
-
{data.pathLengths.map((bin) => )}
Header entriesReceptions
{bin.entries}{bin.receptions.toLocaleString()}
+ {data.hashWidths.map((bin) => )}
{t("paths.bytesPerHash")}{t("paths.receptionsColumn")}
{bin.bytes}{bin.receptions.toLocaleString()}
+
{data.pathLengths.map((bin) => )}
{t("paths.headerEntries")}{t("paths.receptionsColumn")}
{bin.entries}{bin.receptions.toLocaleString()}
-

Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.

-
{["UTC hour", "Receptions", "1-byte", "2-byte", "3-byte", "Empty", "Trace", "Unclassified"].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{utc(row.hour)}{n.toLocaleString()}
+

{t("paths.lengthsHelp")}

+
{[t("paths.utcHour"), t("paths.receptionsColumn"), ...[1, 2, 3].map((count) => t("paths.hashWidth", { count })), t("paths.categories.empty"), t("paths.categories.trace"), t("paths.categories.unclassified")].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{utc(row.hour)}{n.toLocaleString()}
}
; } diff --git a/src/features/stats/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx index 76b0270d..2a2e5eb3 100644 --- a/src/features/stats/ScopesTab.tsx +++ b/src/features/stats/ScopesTab.tsx @@ -1,4 +1,5 @@ import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { useScopes } from "./useStats"; import { useChartColors } from "./chartTheme"; import { Card, ChartCard, StatCard } from "./cards"; @@ -6,6 +7,7 @@ import { scopeChartOption, scopeSummary } from "./scopes"; import { formatCount } from "../../lib/formatters"; export function ScopesTab() { + const { t } = useTranslation(); const query = useScopes(); const colors = useChartColors(); const [search, setSearch] = useState(""); @@ -14,44 +16,44 @@ export function ScopesTab() { const all = useMemo(() => unavailable ? [] : (query.data ?? []), [query.data, unavailable]); const rows = useMemo(() => all.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase())).sort((a, b) => b.packetCount - a.packetCount || a.name.localeCompare(b.name)), [all, search]); const totals = useMemo(() => scopeSummary(rows), [rows]); - const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors), [rows, colors]); - const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors), [rows, colors]); - const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors), [rows, colors]); + const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors, t), [rows, colors, t]); + const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors, t), [rows, colors, t]); + const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors, t), [rows, colors, t]); const value = (number: number) => unavailable ? "—" : formatCount(number); const height = Math.max(180, Math.min(13, rows.length) * 28 + 16); return (
-

Transport scopes

Retained data and memberships for the selected region.

- +

{t("scopes.title")}

{t("scopes.subtitle")}

+
-
diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index a6e40b0c..400dbf59 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -1,4 +1,5 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { formatCount } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { useChartColors } from "./chartTheme"; @@ -10,61 +11,62 @@ const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", const average = (value: number | null | undefined, unit = "") => value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; export function SignalTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); const query = useSignalStats(range); const c = useChartColors(); const loading = query.isPending || query.isPlaceholderData; const data = loading || query.isError ? undefined : query.data; const hours = useMemo(() => signalHours(data), [data]); const charts = useMemo(() => ({ - snr: signalHistogramOption(data?.snr, "SNR", "dB", c), rssi: signalHistogramOption(data?.rssi, "RSSI", "dBm", c), - snrTrend: signalTrendOption(hours, "snr", c), rssiTrend: signalTrendOption(hours, "rssi", c), coverage: signalCoverageOption(data, c), - }), [data, hours, c]); + snr: signalHistogramOption(data?.snr, "SNR", "dB", c, t), rssi: signalHistogramOption(data?.rssi, "RSSI", "dBm", c, t), + snrTrend: signalTrendOption(hours, "snr", c, t), rssiTrend: signalTrendOption(hours, "rssi", c, t), coverage: signalCoverageOption(data, c, t), + }), [data, hours, c, t]); const state = { isLoading: loading, isError: query.isError }; return (
-

RF / Signal

The signal behind reported receptions.

- +

{t("signal.title")}

{t("signal.subtitle")}

+
- {query.isError &&

Could not load signal data. Try refreshing or choosing a shorter time period.

} + {query.isError &&

{t("signal.error")}

}
- - {average(data?.snr.average, "dB")}} accent={c.secondary} /> - {average(data?.rssi.average, "dBm")}} accent={c.green} /> - + + {average(data?.snr.average, "dB")}} accent={c.secondary} /> + {average(data?.rssi.average, "dBm")}} accent={c.green} /> +
-

Signal measured by observers on the last hop. Charts count retained receptions and use UTC time.

- {data &&

Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours do not establish an outage.

} +

{t("signal.measurement")}

+ {data &&

{t("signal.window", { since: utc(data.since), until: utc(data.until) })}

}
- - - - + + + +
- - - {!data ?

{query.isError ? "Data unavailable" : "Loading signal…"}

: !data.receptions ?

No retained receptions in this window.

: ( - - - {(["snr", "rssi"] as const).map((metric) => )} -
MetricSamplesMissingShare
{metric.toUpperCase()}{data[metric].samples.toLocaleString()}{(data.receptions - data[metric].samples).toLocaleString()}{(100 * data[metric].samples / data.receptions).toFixed(1)}%
+ + + {!data ?

{query.isError ? t("common.dataUnavailable") : t("signal.loading")}

: !data.receptions ?

{t("signal.empty")}

: ( +
+ + {(["snr", "rssi"] as const).map((metric) => )} +
{t("signal.metric")}{t("signal.samples")}{t("signal.missing")}{t("signal.share")}
{metric.toUpperCase()}{data[metric].samples.toLocaleString()}{(data.receptions - data[metric].samples).toLocaleString()}{(100 * data[metric].samples / data.receptions).toFixed(1)}%
)} -

Each mean weights every available reception equally. Missing includes absent, invalid and zero/zero unavailable readings; a measured zero SNR remains valid. SNR and RSSI can have different sample counts. These readings do not measure end-to-end quality or packet loss, and colors are not quality thresholds.

+

{t("signal.averagesHelp")}

{data && data.receptions > 0 &&
- Exact histogram and hourly values + {t("signal.details")}
- {(["snr", "rssi"] as const).map((metric) => - - {data[metric].histogram.map((bin, i) => )} + {(["snr", "rssi"] as const).map((metric) =>
{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}Samples
{signalBinLabel(bin)}{bin.count.toLocaleString()}
+ + {data[metric].histogram.map((bin, i) => )}
{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}{t("signal.samples")}
{signalBinLabel(bin, t)}{bin.count.toLocaleString()}
)}
-

Bins include the lower bound and exclude the upper bound. The outer bins include all values beyond the display range. Hourly means below are rounded to one decimal place.

-
- +

{t("signal.binsHelp")}

+
UTC hourReceptionsSNR samplesMean dBRSSI samplesMean dBm
+ {data.hourly.map((row) => )}
{t("signal.utcHour")}{t("signal.receptionsColumn")}{t("signal.metricSamples", { metric: "SNR" })}{t("signal.meanUnit", { unit: "dB" })}{t("signal.metricSamples", { metric: "RSSI" })}{t("signal.meanUnit", { unit: "dBm" })}
{utc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
} diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index 9f188623..aaf201f2 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -1,4 +1,5 @@ import { Segmented } from "./Segmented"; +import { useTranslation } from "react-i18next"; import { SelectDropdown } from "../../components/SelectDropdown"; import { useIsMobile } from "../../hooks/useMediaQuery"; import type { StatsRange, StatsTab } from "./types"; @@ -87,26 +88,19 @@ function GraphIcon() { } const TAB_OPTIONS = [ - { value: "mesh", label: "Mesh", icon: }, - { value: "traffic", label: "Traffic", icon: }, - { value: "signal", label: "RF / Signal", icon: }, - { value: "paths", label: "Paths & Hashes", icon: }, - { value: "scopes", label: "Scopes", icon: }, - { value: "talkers", label: "Talkers", icon: }, - { value: "clockdrift", label: "Clock Drift", icon: }, - { value: "observer", label: "Observer", icon: }, - { value: "compare", label: "Compare observers", icon: }, - { value: "graph", label: "Neighbour Graph", icon: }, + { value: "mesh", icon: }, + { value: "traffic", icon: }, + { value: "signal", icon: }, + { value: "paths", icon: }, + { value: "scopes", icon: }, + { value: "talkers", icon: }, + { value: "clockdrift", icon: }, + { value: "observer", icon: }, + { value: "compare", icon: }, + { value: "graph", icon: }, ]; -// Same sections, minus icons, for the mobile dropdown (which is text-only). Scales with TAB_OPTIONS. -const TAB_SELECT_OPTIONS = TAB_OPTIONS.map(({ value, label }) => ({ value, label })); - -const RANGE_OPTIONS = [ - { value: "24h", label: "24h" }, - { value: "7d", label: "7d" }, - { value: "30d", label: "30d" }, -]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; interface Props { tab: StatsTab; @@ -116,26 +110,29 @@ interface Props { } export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props) { + const { t } = useTranslation(); const isMobile = useIsMobile(); + const tabOptions = TAB_OPTIONS.map((option) => ({ ...option, label: t(`stats.tabs.${option.value}`) })); + const rangeOptions = RANGES.map((value) => ({ value, label: t(`stats.ranges.${value}`) })); return (
{/* pills don't scale on a phone as sections grow — swap to a compact dropdown there */} {isMobile ? ( onTabChange(v as StatsTab)} /> ) : (
onTabChange(v as StatsTab)} - ariaLabel="Stats section" + ariaLabel={t("stats.section")} size="md" />
@@ -144,10 +141,10 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && ( onRangeChange(v as StatsRange)} - ariaLabel="Time range" + ariaLabel={t("stats.timeRange")} /> )}
diff --git a/src/features/stats/TrafficTab.tsx b/src/features/stats/TrafficTab.tsx index e469cd3b..bdd1289c 100644 --- a/src/features/stats/TrafficTab.tsx +++ b/src/features/stats/TrafficTab.tsx @@ -1,55 +1,57 @@ import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { formatCount } from "../../lib/formatters"; import { useStatsObservations } from "./useStats"; import { tooltipStyle, useChartColors } from "./chartTheme"; import { donutOption } from "./chartOptions"; import { Card, ChartCard, StatCard } from "./cards"; -import { trafficModel, trafficHeatmapOption, trafficTrendOption } from "./traffic"; +import { trafficAreaLabel, trafficModel, trafficHeatmapOption, trafficTrendOption } from "./traffic"; import type { StatsRange } from "./types"; export function TrafficTab({ range }: { range: StatsRange }) { + const { t } = useTranslation(); const query = useStatsObservations(range); const colors = useChartColors(); const loading = query.isPending || query.isLoading || query.isPlaceholderData; const unavailable = loading || query.isError; const model = useMemo(() => trafficModel(unavailable ? [] : (query.data ?? []), range, query.dataUpdatedAt), [query.data, query.dataUpdatedAt, range, unavailable]); - const trend = useMemo(() => trafficTrendOption(model, colors), [model, colors]); - const heatmap = useMemo(() => trafficHeatmapOption(model, colors), [model, colors]); + const trend = useMemo(() => trafficTrendOption(model, colors, t), [model, colors, t]); + const heatmap = useMemo(() => trafficHeatmapOption(model, colors, t), [model, colors, t]); const share = useMemo(() => { - const option = donutOption(model.series.map((area, i) => ({ name: area.name, value: area.total, color: colors.series[i] })), colors, formatCount(model.total), "RECEPTIONS"); - return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: "Reception share by IATA. Exact counts and percentages follow in the table." } } }; - }, [model, colors]); + const option = donutOption(model.series.map((area, i) => ({ name: trafficAreaLabel(area.name, t), value: area.total, color: colors.series[i] })), colors, formatCount(model.total), t("traffic.receptionsCenter")); + return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: t("traffic.shareDescription") } } }; + }, [model, colors, t]); const value = (number: number) => unavailable ? "—" : formatCount(number); return (
-

Traffic

When and where observers report receptions.

- +

{t("stats.tabs.traffic")}

{t("traffic.subtitle")}

+
- {query.isError &&

Could not load traffic. Try refreshing.

} + {query.isError &&

{t("traffic.error")}

}
- - area.total > 0).length)} accent={colors.green} /> - - + + area.total > 0).length)} accent={colors.green} /> + +
-

Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.

- Available hourly records} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> - +

{t("traffic.measurement")}

+ {t("traffic.hourlyRecords")}} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} /> +
- - - {unavailable ?

{query.isError ? "Data unavailable" : "Loading traffic…"}

: !model.areas.length ?

No retained receptions in this window.

: ( -
- + + + {unavailable ?

{query.isError ? t("common.dataUnavailable") : t("traffic.loading")}

: !model.areas.length ?

{t("traffic.empty")}

: ( +
IATAReceptionsShare
+ {model.areas.map((area, i) => - - + + )}
IATA{t("traffic.receptionsColumn")}{t("traffic.share")}
{area.name}{area.total.toLocaleString()}{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%{trafficAreaLabel(area.name, t)}{area.total.toLocaleString()}{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%
)} -

Charts group smaller areas into “Other IATAs”; this table keeps every area.

+

{t("traffic.groupingHelp")}

diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 6dd46119..6eaa1170 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { EChart } from "./EChart"; import type { EChartsOption } from "./echarts-setup"; @@ -60,7 +61,7 @@ export function StatCard({
{label} - {sublabel && {sublabel}} + {sublabel && {sublabel}}
{value}
{spark ? :
} @@ -96,15 +97,16 @@ export function ChartCard({ onEvents?: Record void>; className?: string; }) { + const { t } = useTranslation(); return (
{isError ? ( - Failed to load + {t("common.loadFailed")} ) : isLoading ? ( - Loading… + {t("common.loading")} ) : isEmpty ? ( - No data + {t("common.noData")} ) : ( )} diff --git a/src/features/stats/paths.ts b/src/features/stats/paths.ts index 6425d080..9e8924ac 100644 --- a/src/features/stats/paths.ts +++ b/src/features/stats/paths.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import type { EChartsOption } from "echarts"; import type { PathLengthBin, PathStats } from "./types"; import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; @@ -13,32 +14,32 @@ export function pathHours(data: PathStats | undefined) { }); } -export function pathLengthOption(bins: PathLengthBin[], c: ChartColors): EChartsOption { +export function pathLengthOption(bins: PathLengthBin[], c: ChartColors, t: TFunction): EChartsOption { const max = Math.min(63, Math.max(0, ...bins.map((bin) => bin.entries))); const counts = new Map(bins.map((bin) => [bin.entries, bin.receptions])); const entries = Array.from({ length: max + 1 }, (_, i) => i); return { animation: false, - aria: { enabled: true, label: { description: "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table." } }, + aria: { enabled: true, label: { description: t("paths.lengthDescription") } }, grid: { left: 8, right: 12, top: 18, bottom: 38, containLabel: true }, tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, - xAxis: { type: "category", data: entries, name: "Path entries", nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, + xAxis: { type: "category", data: entries, name: t("paths.entries"), nameLocation: "middle", nameGap: 25, nameTextStyle: { color: c.textMuted }, axisLabel: { color: c.textMuted, fontSize: 10 }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, - series: [{ name: "Receptions", type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], + series: [{ name: t("paths.receptionsColumn"), type: "bar", barMaxWidth: 26, data: entries.map((i) => ({ value: counts.get(i) ?? 0, itemStyle: { color: i === 0 ? c.textDim : blend(c.primary, c.secondary, i / Math.max(1, max)), borderRadius: [3, 3, 0, 0] } })) }], }; } -export function pathTrendOption(hours: ReturnType, c: ChartColors): EChartsOption { +export function pathTrendOption(hours: ReturnType, c: ChartColors, t: TFunction): EChartsOption { return { animation: false, useUTC: true, - aria: { enabled: true, label: { description: "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table." } }, + aria: { enabled: true, label: { description: t("paths.trendDescription") } }, legend: { top: 0, textStyle: { color: c.textNormal, fontSize: 10 }, itemWidth: 10, itemHeight: 10 }, grid: { left: 8, right: 18, top: 35, bottom: 15, containLabel: true }, tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, series: (["oneByte", "twoByte", "threeByte"] as const).map((field, i) => ({ - name: `${i + 1}-byte`, type: "line", stack: "hash-paths", connectNulls: false, showSymbol: true, symbolSize: 4, + name: t("paths.hashWidth", { count: i + 1 }), type: "line", stack: "hash-paths", connectNulls: false, showSymbol: true, symbolSize: 4, lineStyle: { width: 1.5, color: c.series[i] }, itemStyle: { color: c.series[i] }, areaStyle: { color: withAlpha(c.series[i]!, 0.32) }, data: hours.map((hour) => [hour.hour, hour[field]]), })), diff --git a/src/features/stats/scopes.ts b/src/features/stats/scopes.ts index e8a6bf0a..1b8ce08c 100644 --- a/src/features/stats/scopes.ts +++ b/src/features/stats/scopes.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import type { ScopeStats } from "./types"; import { leaderboardOption } from "./chartOptions"; import { tooltipStyle, type ChartColors } from "./chartTheme"; @@ -14,12 +15,12 @@ export function scopeSummary(rows: ScopeStats[]) { }), { active: 0, packets: 0, memberships: 0, nodes: 0 }); } -export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors: ChartColors): EChartsOption { +export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors: ChartColors, t: TFunction): EChartsOption { const indices = new Map(rows.map((row) => row.name).sort().map((name, index) => [name, index])); const ranked = rows.map((row) => ({ name: row.name, value: row[metric], color: colors.series[(indices.get(row.name) ?? 0) % colors.series.length] ?? colors.primary })) .sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); const shown = ranked.slice(0, 12); - if (ranked.length > 12) shown.push({ name: "Other scopes", value: ranked.slice(12).reduce((sum, row) => sum + row.value, 0), color: colors.textDim }); + if (ranked.length > 12) shown.push({ name: t("scopes.other"), value: ranked.slice(12).reduce((sum, row) => sum + row.value, 0), color: colors.textDim }); return { ...leaderboardOption(shown, colors, 126), tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, - aria: { enabled: true, label: { description: "Counts by transport scope. Exact values for all matching scopes are listed in the table." } } }; + aria: { enabled: true, label: { description: t("scopes.chartDescription") } } }; } diff --git a/src/features/stats/signal.ts b/src/features/stats/signal.ts index e39c7137..dddad107 100644 --- a/src/features/stats/signal.ts +++ b/src/features/stats/signal.ts @@ -1,4 +1,5 @@ import type { EChartsOption } from "echarts"; +import type { TFunction } from "i18next"; import type { SignalBin, SignalMetric, SignalStats } from "./types"; import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; @@ -17,33 +18,33 @@ export function signalHours(data: SignalStats | undefined) { }); } -export function signalBinLabel(bin: SignalBin) { +export function signalBinLabel(bin: SignalBin, t: TFunction) { if (bin.lower === null) return `< ${bin.upper}`; if (bin.upper === null) return `≥ ${bin.lower}`; - return `${bin.lower} to < ${bin.upper}`; + return t("signal.binInterval", { lower: bin.lower, upper: bin.upper }); } -export function signalHistogramOption(metric: SignalMetric | undefined, name: string, unit: string, c: ChartColors): EChartsOption { +export function signalHistogramOption(metric: SignalMetric | undefined, name: string, unit: string, c: ChartColors, t: TFunction): EChartsOption { const bins = metric?.histogram ?? []; return { animation: false, - aria: { enabled: true, label: { description: `${name} distribution in ${unit}. Exact bin boundaries and counts follow below.` } }, + aria: { enabled: true, label: { description: t("signal.histogramDescription", { metric: name, unit }) } }, grid: { left: 8, right: 10, top: 20, bottom: 16, containLabel: true }, tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, - xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } }, + xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin, t)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, - series: [{ name: `${name} samples`, type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], + series: [{ name: t("signal.metricSamples", { metric: name }), type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }], }; } -export function signalTrendOption(hours: ReturnType, metric: "snr" | "rssi", c: ChartColors): EChartsOption { +export function signalTrendOption(hours: ReturnType, metric: "snr" | "rssi", c: ChartColors, t: TFunction): EChartsOption { const color = metric === "snr" ? c.secondary : c.green; const unit = metric === "snr" ? "dB" : "dBm"; return { animation: false, useUTC: true, - aria: { enabled: true, label: { description: `Hourly mean ${metric.toUpperCase()} in ${unit}, UTC. Missing readings remain gaps. Exact values are in the hourly table.` } }, + aria: { enabled: true, label: { description: t("signal.trendDescription", { metric: metric.toUpperCase(), unit }) } }, grid: { left: 8, right: 18, top: 20, bottom: 16, containLabel: true }, - tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c), valueFormatter: (value) => typeof value === "number" ? `${value.toFixed(2)} ${unit}` : "No sample" }, + tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c), valueFormatter: (value) => typeof value === "number" ? `${value.toFixed(2)} ${unit}` : t("signal.noSample") }, xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } }, yAxis: { type: "value", scale: true, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } }, series: [{ name: `${metric.toUpperCase()} · ${unit}`, type: "line", connectNulls: false, showSymbol: true, symbolSize: 5, @@ -52,18 +53,18 @@ export function signalTrendOption(hours: ReturnType, metric: }; } -export function signalCoverageOption(data: SignalStats | undefined, c: ChartColors): EChartsOption { +export function signalCoverageOption(data: SignalStats | undefined, c: ChartColors, t: TFunction): EChartsOption { return { animation: false, - aria: { enabled: true, label: { description: "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table." } }, + aria: { enabled: true, label: { description: t("signal.coverageDescription") } }, grid: { left: 8, right: 12, top: 30, bottom: 12, containLabel: true }, legend: { top: 0, textStyle: { color: c.textMuted } }, tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, - xAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted }, splitLine: { lineStyle: { color: c.borderSubtle } } }, + xAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, hideOverlap: true }, splitLine: { lineStyle: { color: c.borderSubtle } } }, yAxis: { type: "category", data: ["SNR", "RSSI"], axisLabel: { color: c.textNormal }, axisLine: { show: false }, axisTick: { show: false } }, series: [ - { name: "Available", type: "bar", stack: "samples", barMaxWidth: 22, itemStyle: { color: c.primary }, data: [data?.snr.samples ?? 0, data?.rssi.samples ?? 0] }, - { name: "Unavailable", type: "bar", stack: "samples", itemStyle: { color: c.warn }, data: [data ? data.receptions - data.snr.samples : 0, data ? data.receptions - data.rssi.samples : 0] }, + { name: t("signal.available"), type: "bar", stack: "samples", barMaxWidth: 22, itemStyle: { color: c.primary }, data: [data?.snr.samples ?? 0, data?.rssi.samples ?? 0] }, + { name: t("signal.unavailable"), type: "bar", stack: "samples", itemStyle: { color: c.warn }, data: [data ? data.receptions - data.snr.samples : 0, data ? data.receptions - data.rssi.samples : 0] }, ], }; } diff --git a/src/features/stats/traffic.ts b/src/features/stats/traffic.ts index bb574ec3..c32448ba 100644 --- a/src/features/stats/traffic.ts +++ b/src/features/stats/traffic.ts @@ -1,3 +1,4 @@ +import type { TFunction } from "i18next"; import type { EChartsOption } from "./echarts-setup"; import { tooltipStyle, type ChartColors } from "./chartTheme"; import { RANGE_MS, type ObservationPoint, type StatsRange } from "./types"; @@ -48,13 +49,17 @@ export function trafficModel(points: ObservationPoint[], range: StatsRange, asOf export type TrafficModel = ReturnType; -export function trafficTrendOption(model: TrafficModel, c: ChartColors): EChartsOption { +export function trafficAreaLabel(name: string, t: TFunction): string { + return name === "Unassigned" ? t("traffic.unassigned") : name === "Other IATAs" ? t("traffic.otherIatas") : name; +} + +export function trafficTrendOption(model: TrafficModel, c: ChartColors, t: TFunction): EChartsOption { const present = model.hours.filter((hour) => hour.total !== null); const first = present[0]?.hour; const last = present.at(-1)?.hour; return { animation: false, useUTC: true, backgroundColor: "transparent", - aria: { enabled: true, label: { description: "Hourly reported receptions by IATA. Gaps mean no retained hourly record. Exact totals follow in the data table." } }, + aria: { enabled: true, label: { description: t("traffic.trendDescription") } }, grid: { left: 54, right: 18, top: 42, bottom: 28 }, tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) }, legend: { type: "scroll", top: 0, left: 0, right: 0, textStyle: { color: c.textNormal, fontSize: 10 }, inactiveColor: c.textDim }, @@ -62,7 +67,7 @@ export function trafficTrendOption(model: TrafficModel, c: ChartColors): ECharts axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } }, splitLine: { show: false } }, yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.border, opacity: 0.4 } } }, series: model.series.map((series, index) => ({ - name: series.name, type: "line", stack: "receptions", connectNulls: false, smooth: false, + name: trafficAreaLabel(series.name, t), type: "line", stack: "receptions", connectNulls: false, smooth: false, symbol: "circle", symbolSize: 5, showSymbol: present.length <= 2, data: model.hours.map((hour, i) => [hour.hour, series.values[i]]), lineStyle: { color: c.series[index], width: 1.5 }, itemStyle: { color: c.series[index] }, areaStyle: { color: c.series[index], opacity: 0.45 }, @@ -70,21 +75,21 @@ export function trafficTrendOption(model: TrafficModel, c: ChartColors): ECharts }; } -export function trafficHeatmapOption(model: TrafficModel, c: ChartColors): EChartsOption { +export function trafficHeatmapOption(model: TrafficModel, c: ChartColors, t: TFunction): EChartsOption { return { animation: false, backgroundColor: "transparent", - aria: { enabled: true, label: { description: "Activity heatmap by UTC day and hour. Brighter cells indicate more reported receptions; blank cells have no retained record." } }, + aria: { enabled: true, label: { description: t("traffic.heatmapDescription") } }, grid: { left: 58, right: 14, top: 8, bottom: 62 }, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(c), formatter: (item: { value: unknown }) => { const value = item.value as [number, number, number]; - return `${model.days[value[1]]} ${String(value[0]).padStart(2, "0")}:00 UTC\n${value[2].toLocaleString()} receptions`; + return t("traffic.heatmapTooltip", { day: model.days[value[1]], hour: String(value[0]).padStart(2, "0"), count: value[2], value: value[2].toLocaleString() }); } }, xAxis: { type: "category", data: Array.from({ length: 24 }, (_, h) => String(h).padStart(2, "0")), axisLabel: { color: c.textMuted, fontSize: 9, interval: 2 }, axisTick: { show: false }, axisLine: { show: false }, splitArea: { show: true, areaStyle: { color: [c.bgRaised, c.bgSurface] } } }, yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false }, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } }, visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130, - text: ["More", "Fewer"], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } }, - series: [{ name: "Receptions", type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }], + text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } }, + series: [{ name: t("traffic.receptionsColumn"), type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }], }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d006194e..396320e1 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -38,7 +38,191 @@ "failed": "Failed to load", "noMatches": "No matches" }, - "theme": { "label": "Theme" }, - "common": { "loading": "Loading…" } + "theme": { + "label": "Theme" + }, + "common": { + "loading": "Loading…", + "loadFailed": "Failed to load", + "noData": "No data", + "dataUnavailable": "Data unavailable" + }, + "stats": { + "section": "Stats section", + "mobileSection": "Section", + "timeRange": "Time range", + "tabs": { + "mesh": "Mesh", + "traffic": "Traffic", + "signal": "RF / Signal", + "paths": "Paths & Hashes", + "scopes": "Scopes", + "talkers": "Talkers", + "clockdrift": "Clock Drift", + "observer": "Observer", + "compare": "Compare observers", + "graph": "Neighbour Graph" + }, + "ranges": { + "24h": "24h", + "7d": "7d", + "30d": "30d" + } + }, + "signal": { + "title": "RF / Signal", + "subtitle": "The signal behind reported receptions.", + "refresh": "Refresh signal", + "error": "Could not load signal data. Try refreshing or choosing a shorter time period.", + "receptions": "Reported receptions", + "meanSnr": "Mean SNR", + "meanRssi": "Mean RSSI", + "hoursWithRecords": "Hours with records", + "measurement": "Signal measured by observers on the last hop. Charts count retained receptions and use UTC time.", + "window": "Window: {{since}} to {{until}} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours do not establish an outage.", + "histogramTitle": "{{metric}} distribution · {{unit}}", + "meanTitle": "Hourly mean {{metric}} · {{unit}}", + "availability": "Sample availability", + "averagesTitle": "What the averages include", + "loading": "Loading signal…", + "empty": "No retained receptions in this window.", + "availabilityTable": "Signal sample availability", + "metric": "Metric", + "samples": "Samples", + "missing": "Missing", + "share": "Share", + "averagesHelp": "Each mean weights every available reception equally. Missing includes absent, invalid and zero/zero unavailable readings; a measured zero SNR remains valid. SNR and RSSI can have different sample counts. These readings do not measure end-to-end quality or packet loss, and colors are not quality thresholds.", + "details": "Exact histogram and hourly values", + "histogramTable": "{{metric}} histogram", + "binsHelp": "Bins include the lower bound and exclude the upper bound. The outer bins include all values beyond the display range. Hourly means below are rounded to one decimal place.", + "hourlyTable": "Hourly signal values", + "utcHour": "UTC hour", + "receptionsColumn": "Receptions", + "metricSamples": "{{metric}} samples", + "meanUnit": "Mean {{unit}}", + "binInterval": "{{lower}} to < {{upper}}", + "histogramDescription": "{{metric}} distribution in {{unit}}. Exact bin boundaries and counts follow below.", + "trendDescription": "Hourly mean {{metric}} in {{unit}}, UTC. Missing readings remain gaps. Exact values are in the hourly table.", + "coverageDescription": "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table.", + "available": "Available", + "unavailable": "Unavailable", + "noSample": "No sample" + }, + "paths": { + "subtitle": "How received packets carry route hashes.", + "refresh": "Refresh paths", + "error": "Could not load path data. Try refreshing or choosing a shorter time period.", + "receptions": "Reported receptions", + "withHashPaths": "With hash paths", + "multiByteShare": "Multi-byte share", + "mostEntries": "Most path entries", + "measurement": "Path entries reported by observers, counted per reception. Hash-width shares use nonempty hash paths.", + "window": "Window: {{since}} to {{until}} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours lack retained records.", + "widthTitle": "Observed hash widths", + "lengthTitle": "Received path entries", + "trendTitle": "Hash-path receptions over time", + "coverageTitle": "Path format coverage", + "countsTitle": "What the counts include", + "loading": "Loading paths…", + "empty": "No retained receptions in this window.", + "classificationTable": "Path classification counts", + "category": "Category", + "receptionsColumn": "Receptions", + "share": "Share", + "categories": { + "hashed": "Hash paths", + "empty": "Empty", + "trace": "Trace", + "unclassified": "Unclassified" + }, + "countsHelp": "Flood paths accumulate entries; direct routes contain remaining entries. Empty paths have no hash-width vote. Trace headers carry signal readings. Unclassified records lack usable payload or path metadata.", + "details": "Exact width, path-length and hourly values", + "widthTable": "Hash width counts", + "bytesPerHash": "Bytes per hash", + "lengthTable": "Path entry counts", + "headerEntries": "Header entries", + "lengthsHelp": "Length counts include validated ordinary empty paths at zero. In the trend, recorded hours with no hash paths show zero; absent hours remain gaps.", + "hourlyTable": "Hourly path counts", + "utcHour": "UTC hour", + "hashWidth_one": "{{count}}-byte", + "hashWidth_other": "{{count}}-byte", + "hashPathsCenter": "HASH PATHS", + "receptionsCenter": "RECEPTIONS", + "widthDescription": "Hash-width share among nonempty ordinary paths. Empty, trace and unclassified records are excluded. Exact counts follow below.", + "coverageDescription": "Path classification for all retained receptions. The four categories partition the total; exact counts follow alongside.", + "lengthDescription": "Received path entries per reception. Zero means an empty ordinary path. Trace and unclassified records are excluded. Exact counts follow in the table.", + "entries": "Path entries", + "trendDescription": "Hourly receptions carrying nonempty 1, 2 or 3-byte hash paths, UTC. Absent hours remain gaps. Exact values follow in the hourly table." + }, + "traffic": { + "subtitle": "When and where observers report receptions.", + "refresh": "Refresh traffic", + "error": "Could not load traffic. Try refreshing.", + "receptions": "Reported receptions", + "reportingIatas": "Reporting IATAs", + "busiestHour": "Busiest hour", + "hoursWithRecords": "Hours with records", + "measurement": "Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.", + "trendTitle": "Reception trend by IATA", + "hourlyRecords": "Available hourly records", + "hourlyTitle": "Hourly activity · {{range}}", + "shareTitle": "Reception share", + "byIata": "Receptions by IATA", + "loading": "Loading traffic…", + "empty": "No retained receptions in this window.", + "receptionsColumn": "Receptions", + "share": "Share", + "groupingHelp": "Charts group smaller areas into “Other IATAs”; this table keeps every area.", + "receptionsCenter": "RECEPTIONS", + "shareDescription": "Reception share by IATA. Exact counts and percentages follow in the table.", + "unassigned": "Unassigned", + "otherIatas": "Other IATAs", + "trendDescription": "Hourly reported receptions by IATA. Gaps mean no retained hourly record. Exact totals follow in the data table.", + "heatmapDescription": "Activity heatmap by UTC day and hour. Brighter cells indicate more reported receptions; blank cells have no retained record.", + "more": "More", + "fewer": "Fewer", + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} reception", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} receptions" + }, + "scopes": { + "title": "Transport scopes", + "subtitle": "Retained data and memberships for the selected region.", + "refresh": "Refresh scopes", + "search": "Find a scope", + "error": "Could not load scopes. Try refreshing.", + "active": "Scopes with data", + "scopedPackets": "Scoped packets", + "memberships": "Observer memberships", + "defaultNodes": "Default-scope nodes", + "measurement": "An observer can appear in more than one scope. Global observer counts include stored memberships; regional counts use retained scoped receptions. Nodes are counted in their default scope. These counts have no selected time window.", + "packetsTitle": "Packets by scope", + "membershipsTitle": "Observer memberships by scope", + "countsTitle": "Scope counts", + "visibleCount_one": "{{visible}} of {{count}} scope", + "visibleCount_other": "{{visible}} of {{count}} scopes", + "loading": "Loading scopes…", + "noMatches": "No scopes match this search.", + "empty": "No scope data available.", + "scope": "Scope", + "packets": "Packets", + "observers": "Observers", + "nodes": "Nodes", + "groupingHelp": "Charts show the largest 12 scopes plus any remainder. The table keeps all matching scopes.", + "other": "Other scopes", + "chartDescription": "Counts by transport scope. Exact values for all matching scopes are listed in the table." + }, + "clockDrift": { + "caption": "Repeaters & room servers out of sync · worst first", + "node": "Node", + "drift": "Drift", + "checked": "Checked", + "inSync": "in sync", + "ahead": "ahead", + "behind": "behind", + "empty": "No repeaters out of sync" + }, + "timestamp": { + "ago": "{{duration}} ago" + } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c557edab..c6594263 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -39,7 +39,194 @@ "failed": "Échec du chargement", "noMatches": "Aucun résultat" }, - "theme": { "label": "Thème" }, - "common": { "loading": "Chargement…" } + "theme": { + "label": "Thème" + }, + "common": { + "loading": "Chargement…", + "loadFailed": "Échec du chargement", + "noData": "Aucune donnée", + "dataUnavailable": "Données indisponibles" + }, + "stats": { + "section": "Section des statistiques", + "mobileSection": "Section", + "timeRange": "Période", + "tabs": { + "mesh": "Réseau", + "traffic": "Trafic", + "signal": "RF / Signal", + "paths": "Chemins et hachages", + "scopes": "Scopes", + "talkers": "Émetteurs", + "clockdrift": "Dérive d’horloge", + "observer": "Observateur", + "compare": "Comparer les observateurs", + "graph": "Graphe des voisins" + }, + "ranges": { + "24h": "24 h", + "7d": "7 j", + "30d": "30 j" + } + }, + "signal": { + "title": "RF / Signal", + "subtitle": "Le signal des réceptions déclarées.", + "refresh": "Actualiser le signal", + "error": "Impossible de charger les données du signal. Réessayez ou choisissez une période plus courte.", + "receptions": "Réceptions déclarées", + "meanSnr": "SNR moyen", + "meanRssi": "RSSI moyen", + "hoursWithRecords": "Heures documentées", + "measurement": "Signal mesuré par les observateurs sur le dernier saut. Les graphiques comptent les réceptions conservées et utilisent l’heure UTC.", + "window": "Période : {{since}} à {{until}} UTC (fin exclue). Les instantanés d’heures complètes sont actualisés en arrière-plan ; l’heure en cours est exclue. Les heures vides ne prouvent pas une panne.", + "histogramTitle": "Distribution du {{metric}} · {{unit}}", + "meanTitle": "Moyenne horaire du {{metric}} · {{unit}}", + "availability": "Disponibilité des échantillons", + "averagesTitle": "Ce que les moyennes incluent", + "loading": "Chargement du signal…", + "empty": "Aucune réception conservée dans cette période.", + "availabilityTable": "Disponibilité des échantillons du signal", + "metric": "Mesure", + "samples": "Échantillons", + "missing": "Manquants", + "share": "Part", + "averagesHelp": "Chaque moyenne accorde le même poids à chaque réception disponible. Les valeurs manquantes incluent les mesures absentes, invalides et les couples zéro/zéro indisponibles ; un SNR mesuré à zéro reste valide. Le SNR et le RSSI peuvent avoir des nombres d’échantillons différents. Ces mesures ne représentent ni la qualité de bout en bout ni la perte de paquets, et les couleurs ne sont pas des seuils de qualité.", + "details": "Valeurs exactes des histogrammes et par heure", + "histogramTable": "Histogramme {{metric}}", + "binsHelp": "Les classes incluent la borne inférieure et excluent la borne supérieure. Les classes extrêmes incluent toutes les valeurs au-delà de la plage affichée. Les moyennes horaires ci-dessous sont arrondies à une décimale.", + "hourlyTable": "Valeurs horaires du signal", + "utcHour": "Heure UTC", + "receptionsColumn": "Réceptions", + "metricSamples": "Échantillons {{metric}}", + "meanUnit": "Moyenne {{unit}}", + "binInterval": "{{lower}} à < {{upper}}", + "histogramDescription": "Distribution du {{metric}} en {{unit}}. Les bornes exactes des classes et leurs effectifs figurent ci-dessous.", + "trendDescription": "Moyenne horaire du {{metric}} en {{unit}}, UTC. Les mesures manquantes restent des lacunes. Les valeurs exactes figurent dans le tableau horaire.", + "coverageDescription": "Mesures disponibles et indisponibles du SNR et du RSSI. Les effectifs et pourcentages exacts figurent dans le tableau de disponibilité des échantillons.", + "available": "Disponibles", + "unavailable": "Indisponibles", + "noSample": "Aucun échantillon" + }, + "paths": { + "subtitle": "Les hachages de route portés par les paquets reçus.", + "refresh": "Actualiser les chemins", + "error": "Impossible de charger les données des chemins. Réessayez ou choisissez une période plus courte.", + "receptions": "Réceptions déclarées", + "withHashPaths": "Avec chemins hachés", + "multiByteShare": "Part multi-octets", + "mostEntries": "Maximum d’entrées", + "measurement": "Entrées de chemin déclarées par les observateurs, comptées par réception. Les parts par largeur de hachage utilisent les chemins hachés non vides.", + "window": "Période : {{since}} à {{until}} UTC (fin exclue). Les instantanés d’heures complètes sont actualisés en arrière-plan ; l’heure en cours est exclue. Les heures vides n’ont pas de données conservées.", + "widthTitle": "Largeurs de hachage observées", + "lengthTitle": "Entrées de chemin reçues", + "trendTitle": "Réceptions avec chemins hachés au fil du temps", + "coverageTitle": "Répartition des formats de chemin", + "countsTitle": "Ce que les effectifs incluent", + "loading": "Chargement des chemins…", + "empty": "Aucune réception conservée dans cette période.", + "classificationTable": "Effectifs par catégorie de chemin", + "category": "Catégorie", + "receptionsColumn": "Réceptions", + "share": "Part", + "categories": { + "hashed": "Chemins hachés", + "empty": "Vides", + "trace": "Trace", + "unclassified": "Non classés" + }, + "countsHelp": "Les chemins de diffusion accumulent des entrées ; les routes directes contiennent les entrées restantes. Les chemins vides ne comptent dans aucune largeur de hachage. Les en-têtes de trace portent des mesures de signal. Les enregistrements non classés n’ont pas de données utiles ou de métadonnées de chemin exploitables.", + "details": "Valeurs exactes des largeurs, longueurs et par heure", + "widthTable": "Effectifs par largeur de hachage", + "bytesPerHash": "Octets par hachage", + "lengthTable": "Effectifs par nombre d’entrées", + "headerEntries": "Entrées d’en-tête", + "lengthsHelp": "Les effectifs par longueur incluent les chemins ordinaires vides validés à zéro entrée. Dans la courbe, les heures documentées sans chemin haché affichent zéro ; les heures absentes restent des lacunes.", + "hourlyTable": "Effectifs horaires des chemins", + "utcHour": "Heure UTC", + "hashWidth_one": "{{count}} octet", + "hashWidth_many": "{{count}} octets", + "hashWidth_other": "{{count}} octets", + "hashPathsCenter": "CHEMINS HACHÉS", + "receptionsCenter": "RÉCEPTIONS", + "widthDescription": "Part de chaque largeur de hachage parmi les chemins ordinaires non vides. Les chemins vides, les traces et les enregistrements non classés sont exclus. Les effectifs exacts figurent ci-dessous.", + "coverageDescription": "Catégories de chemin pour toutes les réceptions conservées. Les quatre catégories répartissent le total ; les effectifs exacts figurent à côté.", + "lengthDescription": "Entrées de chemin reçues par réception. Zéro indique un chemin ordinaire vide. Les traces et les enregistrements non classés sont exclus. Les effectifs exacts figurent dans le tableau.", + "entries": "Entrées du chemin", + "trendDescription": "Réceptions horaires portant des chemins non vides avec des hachages de 1, 2 ou 3 octets, UTC. Les heures absentes restent des lacunes. Les valeurs exactes figurent dans le tableau horaire." + }, + "traffic": { + "subtitle": "Quand et où les observateurs déclarent des réceptions.", + "refresh": "Actualiser le trafic", + "error": "Impossible de charger le trafic. Réessayez.", + "receptions": "Réceptions déclarées", + "reportingIatas": "IATA actifs", + "busiestHour": "Pic horaire", + "hoursWithRecords": "Heures documentées", + "measurement": "Les effectifs comptent les réceptions déclarées, pas les paquets uniques. Les heures vides n’ont pas de données horaires conservées ; elles ne prouvent pas une panne. Les horaires utilisent l’heure UTC et la dernière heure peut être partielle.", + "trendTitle": "Évolution des réceptions par IATA", + "hourlyRecords": "Données horaires disponibles", + "hourlyTitle": "Activité horaire · {{range}}", + "shareTitle": "Part des réceptions", + "byIata": "Réceptions par IATA", + "loading": "Chargement du trafic…", + "empty": "Aucune réception conservée dans cette période.", + "receptionsColumn": "Réceptions", + "share": "Part", + "groupingHelp": "Les graphiques regroupent les zones moins actives dans « Autres IATA » ; ce tableau conserve toutes les zones.", + "receptionsCenter": "RÉCEPTIONS", + "shareDescription": "Part des réceptions par IATA. Les effectifs et pourcentages exacts figurent dans le tableau.", + "unassigned": "Non attribué", + "otherIatas": "Autres IATA", + "trendDescription": "Réceptions horaires déclarées par IATA. Les lacunes indiquent l’absence de données horaires conservées. Les totaux exacts figurent dans le tableau.", + "heatmapDescription": "Carte thermique de l’activité par jour et heure UTC. Les cellules plus lumineuses indiquent davantage de réceptions déclarées ; les cellules vides n’ont pas de données conservées.", + "more": "Plus", + "fewer": "Moins", + "heatmapTooltip_one": "{{day}} {{hour}}:00 UTC\n{{value}} réception", + "heatmapTooltip_many": "{{day}} {{hour}}:00 UTC\n{{value}} réceptions", + "heatmapTooltip_other": "{{day}} {{hour}}:00 UTC\n{{value}} réceptions" + }, + "scopes": { + "title": "Scopes de transport", + "subtitle": "Données conservées et appartenances pour la région sélectionnée.", + "refresh": "Actualiser les scopes", + "search": "Rechercher un scope", + "error": "Impossible de charger les scopes. Réessayez.", + "active": "Scopes avec données", + "scopedPackets": "Paquets avec scope", + "memberships": "Appartenances d’observateurs", + "defaultNodes": "Nœuds du scope par défaut", + "measurement": "Un observateur peut figurer dans plusieurs scopes. Les effectifs globaux d’observateurs incluent les appartenances enregistrées ; les effectifs régionaux utilisent les réceptions conservées associées aux scopes. Les nœuds sont comptés dans leur scope par défaut. Ces effectifs sont calculés sans période sélectionnée.", + "packetsTitle": "Paquets par scope", + "membershipsTitle": "Appartenances d’observateurs par scope", + "countsTitle": "Effectifs des scopes", + "visibleCount_one": "{{visible}} sur {{count}} scope", + "visibleCount_many": "{{visible}} sur {{count}} scopes", + "visibleCount_other": "{{visible}} sur {{count}} scopes", + "loading": "Chargement des scopes…", + "noMatches": "Aucun scope ne correspond à cette recherche.", + "empty": "Aucune donnée de scope disponible.", + "scope": "Scope", + "packets": "Paquets", + "observers": "Observateurs", + "nodes": "Nœuds", + "groupingHelp": "Les graphiques affichent les 12 scopes les plus importants et regroupent le reste. Le tableau conserve tous les scopes correspondants.", + "other": "Autres scopes", + "chartDescription": "Effectifs par scope de transport. Les valeurs exactes de tous les scopes correspondants figurent dans le tableau." + }, + "clockDrift": { + "caption": "Répéteurs et serveurs de salon désynchronisés · écart maximal en premier", + "node": "Nœud", + "drift": "Dérive", + "checked": "Vérifié", + "inSync": "synchronisé", + "ahead": "en avance", + "behind": "en retard", + "empty": "Aucun répéteur désynchronisé" + }, + "timestamp": { + "ago": "il y a {{duration}}" + } } } diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 2dc030b6..22b30a59 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -61,9 +61,9 @@ export function formatUptime(seconds: number): string { // Signed device-clock drift for the node detail, e.g. "+42s ahead", "-1h 1m behind", "in sync". // formatUptime floors to whole minutes and is unsigned, so it can't render sub-minute drift. // +ve = device clock ahead of the server (matches clockDriftSeconds). -export function formatClockDrift(seconds: number): string { - if (seconds === 0) return "in sync"; - const dir = seconds > 0 ? "ahead" : "behind"; +export function formatClockDrift(seconds: number, labels = { inSync: "in sync", ahead: "ahead", behind: "behind" }): string { + if (seconds === 0) return labels.inSync; + const dir = seconds > 0 ? labels.ahead : labels.behind; const sign = seconds > 0 ? "+" : "-"; const s = Math.abs(seconds); const h = Math.floor(s / 3600); diff --git a/tests/components/DataTable.test.tsx b/tests/components/DataTable.test.tsx index 02594c3a..382b2587 100644 --- a/tests/components/DataTable.test.tsx +++ b/tests/components/DataTable.test.tsx @@ -2,6 +2,31 @@ import { describe, expect, it, vi, afterEach } from "vitest"; import { render, fireEvent, screen } from "@testing-library/react"; import { DataTable, type Column } from "../../src/components/DataTable"; +it("keeps a stable column's default sort and focused button when its label changes", () => { + const data = [{ id: "a" }, { id: "b" }]; + const translatedColumns = (header: string): Column[] => [{ id: "identity", header, cell: (r) => r.id, sortValue: (r) => r.id }]; + const view = (header: string) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ id: "identity", direction: "desc" }} />; + const { container, rerender } = render(view("ID")); + const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent); + expect(order()).toEqual(["b", "a"]); + const button = screen.getByRole("button", { name: /^ID\s*▼$/ }); + button.focus(); + rerender(view("Identifiant")); + expect(order()).toEqual(["b", "a"]); + expect(screen.getByRole("button", { name: /^Identifiant\s*▼$/ })).toBe(button); + expect(button).toHaveFocus(); + fireEvent.click(button); + expect(order()).toEqual(["a", "b"]); +}); + +it("retains header-based default and user sorting for callers without column IDs", () => { + const { container } = render( r.id, sortValue: (r) => r.id }]} rows={rows} rowKey={(r) => r.id} selectedKey={null} onSelect={() => {}} emptyLabel="none" defaultSort={{ header: "ID", direction: "desc" }} />); + const order = () => [...container.querySelectorAll("tbody tr")].map((r) => r.textContent); + expect(order()).toEqual(["b", "a"]); + fireEvent.click(screen.getByRole("button", { name: /^ID\s*▼$/ })); + expect(order()).toEqual(["a", "b"]); +}); + interface Row { id: string; } diff --git a/tests/components/Timestamp.test.tsx b/tests/components/Timestamp.test.tsx index 1c4fd5f8..c8bd64ad 100644 --- a/tests/components/Timestamp.test.tsx +++ b/tests/components/Timestamp.test.tsx @@ -1,6 +1,12 @@ -import { describe, expect, it } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, cleanup, render, screen, fireEvent } from "@testing-library/react"; import { Timestamp } from "../../src/components/Timestamp"; +import i18n from "../../src/i18n"; + +afterEach(() => { + cleanup(); + vi.useRealTimers(); +}); describe("Timestamp", () => { it("shows relative text, revealing the absolute time on hover (instant tooltip)", () => { @@ -25,4 +31,57 @@ describe("Timestamp", () => { render(); expect(screen.getByText(/\.123$/)).toBeInTheDocument(); }); + + it.each([ + [59_999, "59s"], + [60_000, "1m"], + [3_600_000, "1h"], + [86_400_000, "1d"], + [-60_000, "0s"], + ])("translates the phrase while preserving duration %s", async (age, duration) => { + vi.useFakeTimers(); + vi.setSystemTime(new Date(2026, 8, 25, 12, 0, 0)); + render(); + const label = screen.getByText(`${duration} ago`); + + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText(`il y a ${duration}`)).toBe(label); + await act(() => i18n.changeLanguage("en")); + expect(screen.getByText(`${duration} ago`)).toBe(label); + }); + + it("updates an open relative tooltip without changing the absolute value or milliseconds", async () => { + vi.useFakeTimers(); + const value = new Date(2026, 8, 25, 12, 34, 56, 123).getTime(); + vi.setSystemTime(value + 5 * 60_000); + render(); + const label = screen.getByText("2026-09-25 12:34:56.123"); + expect(label).toHaveClass("timestamp-label"); + fireEvent.mouseEnter(label); + expect(screen.getByRole("tooltip")).toHaveTextContent("5m ago"); + + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("tooltip")).toHaveTextContent("il y a 5m"); + expect(screen.getByText("2026-09-25 12:34:56.123")).toBe(label); + expect(label).toHaveClass("timestamp-label"); + }); + + it("keeps one shared timer for many translated timestamps and releases it on unmount", async () => { + // Count polling intervals without counting React's asynchronous scheduling. + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(new Date(2026, 8, 25, 12, 0, 0)); + const value = Date.now() - 60_000; + const { unmount } = render(<>{Array.from({ length: 100 }, (_, id) => )}); + expect(screen.getAllByText("1m ago")).toHaveLength(100); + expect(vi.getTimerCount()).toBe(1); + + await act(() => i18n.changeLanguage("fr")); + expect(screen.getAllByText("il y a 1m")).toHaveLength(100); + expect(vi.getTimerCount()).toBe(1); + act(() => vi.advanceTimersByTime(60_000)); + expect(screen.getAllByText("il y a 2m")).toHaveLength(100); + expect(vi.getTimerCount()).toBe(1); + unmount(); + expect(vi.getTimerCount()).toBe(0); + }); }); diff --git a/tests/features/stats/ClockDriftTab.test.tsx b/tests/features/stats/ClockDriftTab.test.tsx new file mode 100644 index 00000000..0823f63c --- /dev/null +++ b/tests/features/stats/ClockDriftTab.test.tsx @@ -0,0 +1,68 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { ClockDriftTab } from "../../../src/features/stats/ClockDriftTab"; +import { useClockDrift } from "../../../src/features/stats/useStats"; +import type { ClockDriftEntry } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; + +const rows: ClockDriftEntry[] = [ + { nodeId: "fixture-a", nodeName: "Alpha", nodeType: 2, nodeTypeName: "Repeater", clockDriftSeconds: 3599, clockCheckedAt: 1000, iatas: [{ iata: "YVR", lastHeard: 1000 }] }, + { nodeId: "fixture-b", nodeName: "Beta", nodeType: 3, nodeTypeName: "Room", clockDriftSeconds: -3600, clockCheckedAt: 2000, iatas: [{ iata: "YOW", lastHeard: 2000 }] }, +]; +const query = { data: rows, isLoading: false, isPending: false, isPlaceholderData: false, isError: false, isFetching: false }; +vi.mock("../../../src/features/stats/useStats", () => ({ useClockDrift: vi.fn(() => query) })); +beforeEach(() => { vi.clearAllMocks(); query.data = rows; query.isLoading = false; query.isPending = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false; }); + +it("translates clock labels and direction without changing signs, thresholds or worst-first ordering", async () => { + await act(() => i18n.changeLanguage("fr")); + const { container } = render(); + expect(useClockDrift).toHaveBeenCalledWith(); + expect(screen.getByText(/Répéteurs et serveurs de salon désynchronisés/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Dérive\s*▼$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Nœud\s*▲$/ })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /^Vérifié\s*▲$/ })).toBeInTheDocument(); + expect(screen.getByText("+59m 59s en avance")).toHaveClass("text-warn"); + expect(screen.getByText("-1h 0m en retard")).toHaveClass("text-danger"); + expect(screen.getByText("YVR")).toBeInTheDocument(); + expect(screen.getByText("YOW")).toBeInTheDocument(); + expect([...container.querySelectorAll("tbody tr")].map((row) => row.querySelector("td")?.textContent)).toEqual(["BetaRoom", "AlphaRepeater"]); +}); + +it("retains a user-selected sort and raw node values across a language change", async () => { + const { container } = render(); + fireEvent.click(screen.getByRole("button", { name: /^Node\s*▲$/ })); + const order = () => [...container.querySelectorAll("tbody tr")].map((row) => row.querySelector("td")?.textContent); + expect(order()).toEqual(["AlphaRepeater", "BetaRoom"]); + await act(() => i18n.changeLanguage("fr")); + expect(order()).toEqual(["AlphaRepeater", "BetaRoom"]); + fireEvent.click(screen.getByRole("button", { name: /^Nœud\s*▲$/ })); + expect(order()).toEqual(["BetaRoom", "AlphaRepeater"]); + expect(screen.getByRole("button", { name: /^Nœud\s*▼$/ })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: /^Dérive\s*▲$/ })); + expect(order()).toEqual(["AlphaRepeater", "BetaRoom"]); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("shows the translated %s state without cached rows", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("Alpha")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + if (state === "isError") expect(screen.getByText("Échec du chargement")).toBeInTheDocument(); + else expect(screen.queryByText("Aucun répéteur désynchronisé")).not.toBeInTheDocument(); +}); + +it("translates an empty result without inventing out-of-sync nodes", async () => { + query.data = []; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucun répéteur désynchronisé")).toBeInTheDocument(); +}); + +it("retains valid values during a healthy background refresh", () => { + query.isFetching = true; + render(); + expect(screen.getByText("Alpha")).toBeInTheDocument(); + expect(screen.getByText("+59m 59s ahead")).toHaveClass("text-warn"); + expect(screen.getByText("-1h 0m behind")).toHaveClass("text-danger"); +}); diff --git a/tests/features/stats/PathsTab.test.tsx b/tests/features/stats/PathsTab.test.tsx index da0091e6..f7fb530c 100644 --- a/tests/features/stats/PathsTab.test.tsx +++ b/tests/features/stats/PathsTab.test.tsx @@ -1,14 +1,54 @@ import { beforeEach, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { PathsTab } from "../../../src/features/stats/PathsTab"; import { usePathStats } from "../../../src/features/stats/usePathStats"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; const query = { data: { since: 0, until: 3600000, receptions: 100, hashed: 60, empty: 20, trace: 10, unclassified: 10, hashWidths: [{ bytes: 1, receptions: 20 }, { bytes: 2, receptions: 30 }, { bytes: 3, receptions: 10 }], pathLengths: [{ entries: 0, receptions: 20 }, { entries: 2, receptions: 60 }], hourly: [{ hour: 0, receptions: 100, oneByte: 20, twoByte: 30, threeByte: 10, empty: 20, trace: 10, unclassified: 10 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; vi.mock("../../../src/features/stats/usePathStats", () => ({ usePathStats: vi.fn(() => query) })); -vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); -beforeEach(() => { query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); +const originalData = query.data; +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); +beforeEach(() => { query.data = originalData; query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("translates classification and exact tables while preserving counts and the requested window", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByRole("heading", { name: "Chemins et hachages" })).toBeInTheDocument(); + expect(usePathStats).toHaveBeenCalledWith("7d"); + expect(screen.getByText("7 j")).toBeInTheDocument(); + expect(screen.getByText("66.7%")).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Effectifs par catégorie de chemin" }); + expect(within(table).getByRole("row", { name: /Chemins hachés.*60.*60.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Vides.*20.*20.0%/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /Non classés.*10.*10.0%/ })).toBeInTheDocument(); + expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("fin exclue"); + fireEvent.click(screen.getByText("Valeurs exactes des largeurs, longueurs et par heure")); + expect(screen.getByRole("table", { name: "Effectifs par largeur de hachage" })).toHaveTextContent("Octets par hachage"); + expect(within(screen.getByRole("table", { name: "Effectifs par nombre d’entrées" })).getByRole("row", { name: "0 20" })).toBeInTheDocument(); + expect(within(screen.getByRole("table", { name: "Effectifs horaires des chemins" })).getByRole("row", { name: "1970-01-01 00:00 100 20 30 10 20 10 10" })).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Actualiser les chemins" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); + +it("redraws all chart labels on language change and keeps exact details open", async () => { + render(); + fireEvent.click(screen.getByText("Exact width, path-length and hourly values")); + const chartOptions = () => vi.mocked(EChart).mock.calls.slice(-4).map(([{ option }]) => option); + const before = chartOptions(); + expect(before).toHaveLength(4); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Valeurs exactes des largeurs, longueurs et par heure").closest("details")).toHaveAttribute("open"); + const after = chartOptions(); + expect(after[0]).toMatchObject({ series: [{ data: [{ name: "1 octet", value: 20 }, { name: "2 octets", value: 30 }, { name: "3 octets", value: 10 }] }] }); + expect(after[1]).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Réceptions", data: [{ value: 20 }, { value: 0 }, { value: 60 }] }] }); + expect(after[2]).toMatchObject({ series: [{ name: "1 octet", data: [[0, 20]] }, { name: "2 octets", data: [[0, 30]] }, { name: "3 octets", data: [[0, 10]] }] }); + expect(after[3]).toMatchObject({ series: [{ data: [{ name: "Chemins hachés", value: 60 }, { name: "Vides", value: 20 }, { name: "Trace", value: 10 }, { name: "Non classés", value: 10 }] }] }); + after.forEach((option, i) => expect(option.aria).not.toEqual(before[i]!.aria)); + expect(query.refetch).not.toHaveBeenCalled(); +}); it("uses only nonempty hash paths for multi-byte share, exposes categories and explains remaining routes", () => { render(); @@ -27,3 +67,26 @@ it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides old value expect(screen.queryAllByTestId("chart")).toHaveLength(0); if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); }); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s without displaying old paths", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("66.7%")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("période plus courte"); + expect(screen.getByText("Données indisponibles")).toBeInTheDocument(); + } else expect(screen.getByText("Chargement des chemins…")).toBeInTheDocument(); +}); + +it("translates empty coverage without inventing hash-width votes or hourly records", async () => { + query.data = { ...originalData, receptions: 0, hashed: 0, empty: 0, trace: 0, unclassified: 0, hashWidths: [], pathLengths: [], hourly: [] }; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée")).toHaveLength(4); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); +}); diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx index d6128eb1..70ffd2c0 100644 --- a/tests/features/stats/ScopesTab.test.tsx +++ b/tests/features/stats/ScopesTab.test.tsx @@ -1,13 +1,77 @@ import { beforeEach, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { ScopesTab } from "../../../src/features/stats/ScopesTab"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; const query = { data: [ { name: "#west", packetCount: 3, observerCount: 2, nodeCount: 2 }, { name: "#east", packetCount: 2, observerCount: 2, nodeCount: 3 }, ], isPending: false, isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const originalData = query.data; vi.mock("../../../src/features/stats/useStats", () => ({ useScopes: () => query })); -vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); -beforeEach(() => { vi.clearAllMocks(); query.isError = false; query.isPending = false; query.isPlaceholderData = false; }); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); +beforeEach(() => { vi.clearAllMocks(); query.data = originalData; query.isError = false; query.isPending = false; query.isPlaceholderData = false; }); + +it("translates scope counts and membership guidance without changing scope names or values", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByRole("heading", { name: "Scopes de transport" })).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Effectifs des scopes" }); + expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); + expect(within(table).getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument(); + expect(screen.getByText("Appartenances d’observateurs")).toBeInTheDocument(); + expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("sans période sélectionnée"); + expect(screen.getByText("2 sur 2 scopes")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Actualiser les scopes" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); + +it("keeps the current search and chart values when changing language", async () => { + render(); + fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: " EAST " } }); + const charts = () => vi.mocked(EChart).mock.calls.slice(-3).map(([{ option }]) => option); + const bars = (chart: ReturnType[number]) => (Array.isArray(chart.series) ? chart.series : [chart.series]).map((series) => series?.data); + const before = charts(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("searchbox", { name: "Rechercher un scope" })).toHaveValue(" EAST "); + expect(screen.getByText("1 sur 2 scopes")).toBeInTheDocument(); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); + expect(screen.getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument(); + charts().forEach((chart, i) => { + expect(bars(chart)).toEqual(bars(before[i]!)); + expect(chart.yAxis).toEqual(before[i]!.yAxis); + expect(chart.aria).not.toEqual(before[i]!.aria); + }); + expect(query.refetch).not.toHaveBeenCalled(); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s and hides stale regional scopes", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.queryByText("#west")).not.toBeInTheDocument(); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("Impossible de charger les scopes"); + expect(screen.getByText("Données indisponibles")).toBeInTheDocument(); + } else expect(screen.getByText("Chargement des scopes…")).toBeInTheDocument(); +}); + +it("distinguishes a translated empty dataset from a search with no match", async () => { + await act(() => i18n.changeLanguage("fr")); + const { rerender } = render(); + const search = screen.getByRole("searchbox", { name: "Rechercher un scope" }); + fireEvent.change(search, { target: { value: "#absent" } }); + expect(screen.getByText("Aucun scope ne correspond à cette recherche.")).toBeInTheDocument(); + expect(screen.getByText("0 sur 2 scopes")).toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + fireEvent.change(search, { target: { value: "" } }); + query.data = []; rerender(); + expect(screen.getByText("Aucune donnée de scope disponible.")).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée")).toHaveLength(3); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); +}); it("shows exact scope counts, keeps membership semantics explicit and filters the whole view", () => { render(); diff --git a/tests/features/stats/SignalTab.test.tsx b/tests/features/stats/SignalTab.test.tsx index a27c7f89..2d502517 100644 --- a/tests/features/stats/SignalTab.test.tsx +++ b/tests/features/stats/SignalTab.test.tsx @@ -1,12 +1,29 @@ import { beforeEach, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { SignalTab } from "../../../src/features/stats/SignalTab"; import { useSignalStats } from "../../../src/features/stats/useSignalStats"; +import i18n from "../../../src/i18n"; +import { EChart } from "../../../src/features/stats/EChart"; +import type { SignalStats } from "../../../src/features/stats/types"; -const query = { data: { since: 0, until: 3600000, receptions: 100, snr: { samples: 80, average: 0, histogram: [{ lower: -5, upper: 0, count: 80 }] }, rssi: { samples: 90, average: -102.5, histogram: [{ lower: -110, upper: -100, count: 90 }] }, hourly: [{ hour: 0, receptions: 100, snrSamples: 80, snrAverage: 0, rssiSamples: 90, rssiAverage: -102.5 }] }, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const query = { data: { since: 0, until: 3600000, receptions: 100, snr: { samples: 80, average: 0, histogram: [{ lower: -5, upper: 0, count: 80 }] }, rssi: { samples: 90, average: -102.5, histogram: [{ lower: -110, upper: -100, count: 90 }] }, hourly: [{ hour: 0, receptions: 100, snrSamples: 80, snrAverage: 0, rssiSamples: 90, rssiAverage: -102.5 }] } as SignalStats, isPending: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const originalData = query.data; vi.mock("../../../src/features/stats/useSignalStats", () => ({ useSignalStats: vi.fn(() => query) })); -vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); -beforeEach(() => { query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); +beforeEach(() => { query.data = originalData; query.isPending = false; query.isPlaceholderData = false; query.isError = false; vi.clearAllMocks(); }); + +it("translates the signal view while retaining the requested window, true zero and exact counts", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByRole("button", { name: "Actualiser le signal" })).toBeInTheDocument(); + expect(useSignalStats).toHaveBeenCalledWith("7d"); + expect(screen.getByText("0.0 dB")).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Disponibilité des échantillons du signal" }); + expect(within(table).getByRole("row", { name: /SNR.*80.*20.*80.0%/ })).toBeInTheDocument(); + expect(screen.getByText(/1970-01-01 00:00.*1970-01-01 01:00.*UTC/)).toHaveTextContent("fin exclue"); + fireEvent.click(screen.getByRole("button", { name: "Actualiser le signal" })); + expect(query.refetch).toHaveBeenCalledOnce(); +}); it("shows measured units, exact sample coverage and refresh without treating zero SNR as missing", () => { render(); @@ -27,3 +44,44 @@ it.each(["isPending", "isPlaceholderData", "isError"] as const)("hides previous expect(screen.queryAllByTestId("chart")).toHaveLength(0); if (state === "isError") expect(screen.getByRole("alert")).toHaveTextContent("shorter"); }); + +it("redraws chart labels on language change while preserving numeric series and open details", async () => { + render(); + fireEvent.click(screen.getByText("Exact histogram and hourly values")); + const details = screen.getByText("Exact histogram and hourly values").closest("details")!; + expect(details).toHaveAttribute("open"); + const plotted = () => vi.mocked(EChart).mock.calls.slice(-5).map(([{ option }]) => { + const series = Array.isArray(option.series) ? option.series : [option.series]; + return series.map((s) => s?.data); + }); + const before = plotted(); + expect(before).toHaveLength(5); + await act(() => i18n.changeLanguage("fr")); + expect(plotted()).toEqual(before); + expect(screen.getByText("Valeurs exactes des histogrammes et par heure").closest("details")).toHaveAttribute("open"); + expect(screen.getByRole("table", { name: "Histogramme SNR" })).toHaveTextContent("-5 à < 0"); + expect(vi.mocked(EChart).mock.lastCall?.[0].option).toMatchObject({ series: [{ name: "Disponibles" }, { name: "Indisponibles" }] }); + expect(query.refetch).not.toHaveBeenCalled(); +}); + +it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates the %s state and hides stale chart values", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("-102.5 dBm")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("période plus courte"); + expect(screen.getAllByText("Échec du chargement").length).toBeGreaterThan(0); + } else expect(screen.getByText("Chargement du signal…")).toBeInTheDocument(); +}); + +it("shows a translated empty state without implying an outage or displaying samples", async () => { + query.data = { ...originalData, receptions: 0, hourly: [], snr: { samples: 0, average: null, histogram: [] }, rssi: { samples: 0, average: null, histogram: [] } }; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument(); + expect(screen.getByText(/ne prouvent pas une panne/)).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée").length).toBeGreaterThan(0); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); +}); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx new file mode 100644 index 00000000..2d0efc38 --- /dev/null +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; +import { StatsSubHeader } from "../../../src/features/stats/StatsSubHeader"; +import i18n from "../../../src/i18n"; + +afterEach(() => vi.restoreAllMocks()); + +describe("translated analytics controls", () => { + it("keeps section and range identifiers when labels change", async () => { + const onTabChange = vi.fn(), onRangeChange = vi.fn(); + render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByRole("group", { name: "Section des statistiques" })).toBeInTheDocument(); + const range = screen.getByRole("group", { name: "Période" }); + expect(within(range).getByRole("button", { name: "7 j" })).toHaveAttribute("aria-pressed", "true"); + expect(onTabChange).not.toHaveBeenCalled(); + expect(onRangeChange).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Chemins et hachages" })); + expect(onTabChange).toHaveBeenCalledWith("paths"); + fireEvent.click(within(range).getByRole("button", { name: "30 j" })); + expect(onRangeChange).toHaveBeenCalledWith("30d"); + }); + + it("uses the same canonical mobile choices and hides ranges for clock drift", async () => { + const media = window.matchMedia("(max-width: 767px)"); + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ ...media, media: query, matches: query === "(max-width: 767px)" })); + await act(() => i18n.changeLanguage("fr")); + const onTabChange = vi.fn(), onRangeChange = vi.fn(); + const { rerender } = render(); + fireEvent.click(screen.getByRole("button", { name: /Section/ })); + fireEvent.click(screen.getByRole("option", { name: "Dérive d’horloge" })); + expect(onTabChange).toHaveBeenCalledWith("clockdrift"); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + rerender(); + expect(screen.queryByRole("group", { name: "Période" })).not.toBeInTheDocument(); + expect(onRangeChange).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/stats/TrafficTab.test.tsx b/tests/features/stats/TrafficTab.test.tsx index 4f82a4c1..7ce82502 100644 --- a/tests/features/stats/TrafficTab.test.tsx +++ b/tests/features/stats/TrafficTab.test.tsx @@ -1,18 +1,80 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { TrafficTab } from "../../../src/features/stats/TrafficTab"; import { useStatsObservations } from "../../../src/features/stats/useStats"; +import { EChart } from "../../../src/features/stats/EChart"; +import i18n from "../../../src/i18n"; -const query = { data: [{ hour: Date.UTC(2026, 8, 19, 12), iata: "YOW", observationCount: 1200, uniquePackets: 9999, activeObservers: 88 }], dataUpdatedAt: Date.UTC(2026, 8, 19, 12, 30), isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const query = { data: [{ hour: Date.UTC(2026, 8, 19, 12), iata: "YOW", observationCount: 1200, uniquePackets: 9999, activeObservers: 88 }], dataUpdatedAt: Date.UTC(2026, 8, 19, 12, 30), isPending: false, isLoading: false, isPlaceholderData: false, isError: false, isFetching: false, refetch: vi.fn() }; +const originalData = query.data; vi.mock("../../../src/features/stats/useStats", () => ({ useStatsObservations: vi.fn(() => query) })); -vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: vi.fn(() =>
) })); beforeEach(() => { - query.isLoading = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false; + query.data = originalData; query.isPending = false; query.isLoading = false; query.isPlaceholderData = false; query.isError = false; query.isFetching = false; vi.clearAllMocks(); }); describe("Traffic page", () => { + it("translates the page and exact table without changing reception counts, IATA codes or ranges", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByRole("heading", { name: "Trafic" })).toBeInTheDocument(); + expect(useStatsObservations).toHaveBeenCalledWith("7d"); + expect(screen.getByText("Activité horaire · 7 j")).toBeInTheDocument(); + const table = screen.getByRole("table", { name: "Réceptions par IATA" }); + expect(within(table).getByRole("row", { name: /YOW.*1,200.*100.0%/ })).toBeInTheDocument(); + expect(screen.queryByText("9,999")).not.toBeInTheDocument(); + expect(screen.getByText(/heure UTC/)).toHaveTextContent("partielle"); + expect(screen.getByText(/ne prouvent pas une panne/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Actualiser le trafic" })); + expect(query.refetch).toHaveBeenCalledOnce(); + }); + + it("redraws grouped and unassigned chart labels on language change without altering data", async () => { + const row = originalData[0]!; + query.data = [row, { ...row, iata: "", observationCount: 9000 }, ...["YVR", "YYZ", "YUL", "YHZ", "YYC", "YEG", "YQB", "YXE"].map((iata, i) => ({ ...row, iata, observationCount: i + 1 }))]; + const chartOptions = () => vi.mocked(EChart).mock.calls.slice(-3).map(([{ option }]) => option); + const numbers = (options: ReturnType) => options.map((option) => + (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data?.map((entry) => + entry && typeof entry === "object" && !Array.isArray(entry) && "value" in entry ? entry.value : entry))); + render(); + const before = chartOptions(), values = numbers(before); + await act(() => i18n.changeLanguage("fr")); + const after = chartOptions(); + expect(numbers(after)).toEqual(values); + after.forEach((option, i) => expect(option.aria).not.toEqual(before[i]!.aria)); + expect(after[0]).toMatchObject({ series: expect.arrayContaining([expect.objectContaining({ name: "Non attribué" }), expect.objectContaining({ name: "Autres IATA" })]) }); + expect(after[2]).toMatchObject({ series: [{ data: expect.arrayContaining([expect.objectContaining({ name: "Autres IATA", value: 6 }), expect.objectContaining({ name: "Non attribué", value: 9000 })]) }] }); + expect(screen.getByRole("row", { name: /Non attribué.*9,000/ })).toBeInTheDocument(); + expect(screen.getByRole("row", { name: /YOW.*1,200/ })).toBeInTheDocument(); + expect(screen.queryByRole("row", { name: /Autres IATA/ })).not.toBeInTheDocument(); + expect(query.refetch).not.toHaveBeenCalled(); + }); + + it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s and hides prior-region data", async (state) => { + query[state] = true; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.queryByText("YOW")).not.toBeInTheDocument(); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + if (state === "isError") { + expect(screen.getByRole("alert")).toHaveTextContent("Impossible de charger le trafic"); + expect(screen.getByText("Données indisponibles")).toBeInTheDocument(); + } else expect(screen.getByText("Chargement du trafic…")).toBeInTheDocument(); + }); + + it("translates the empty view without inventing receptions or implying an outage", async () => { + query.data = []; + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument(); + expect(screen.getByText(/ne prouvent pas une panne/)).toBeInTheDocument(); + expect(screen.getAllByText("Aucune donnée")).toHaveLength(3); + expect(screen.queryByRole("table")).not.toBeInTheDocument(); + }); + it("shows exact reception counts, UTC/missing-history guidance and a refresh action", () => { render(); expect(useStatsObservations).toHaveBeenCalledWith("24h"); diff --git a/tests/features/stats/clock-query.test.tsx b/tests/features/stats/clock-query.test.tsx new file mode 100644 index 00000000..61a6e9aa --- /dev/null +++ b/tests/features/stats/clock-query.test.tsx @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { act, render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ClockDriftTab } from "../../../src/features/stats/ClockDriftTab"; +import { getClockDrift } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YVR"], regionKey: "YVR" }) })); +vi.mock("../../../src/api/client", () => ({ getClockDrift: vi.fn(() => Promise.resolve([])) })); +afterEach(() => vi.clearAllMocks()); + +it("reuses the regional clock query and limit when only the language changes", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No repeaters out of sync"); + expect(getClockDrift).toHaveBeenCalledOnce(); + expect(getClockDrift).toHaveBeenCalledWith(["YVR"], 100); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucun répéteur désynchronisé")).toBeInTheDocument(); + expect(getClockDrift).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-clock-drift", "YVR", 100]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/path-query.test.tsx b/tests/features/stats/path-query.test.tsx index d0071c64..dea81636 100644 --- a/tests/features/stats/path-query.test.tsx +++ b/tests/features/stats/path-query.test.tsx @@ -1,14 +1,17 @@ import { afterEach, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { usePathStats } from "../../../src/features/stats/usePathStats"; import { getPathStats } from "../../../src/api/client"; import type { StatsRange } from "../../../src/features/stats/types"; +import { PathsTab } from "../../../src/features/stats/PathsTab"; +import i18n from "../../../src/i18n"; const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); vi.mock("../../../src/api/client", () => ({ getPathStats: vi.fn(() => new Promise(() => {})) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { @@ -31,3 +34,17 @@ it("blocks unresolved regions and then uses bounded minute windows and cancellat expect(second[2]).toEqual(["YOW"]); unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); }); + +it("retains the same cached path request and window when only the language changes", async () => { + vi.mocked(getPathStats).mockResolvedValueOnce({ since: 0, until: 3_600_000, receptions: 0, + hashed: 0, empty: 0, trace: 0, unclassified: 0, hashWidths: [], pathLengths: [], hourly: [] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No retained receptions in this window."); + expect(getPathStats).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument(); + expect(getPathStats).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-paths", "YVR", "7d"]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/paths.test.ts b/tests/features/stats/paths.test.ts index 3dcbe611..98e16c82 100644 --- a/tests/features/stats/paths.test.ts +++ b/tests/features/stats/paths.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { pathHours, pathLengthOption, pathTrendOption } from "../../../src/features/stats/paths"; import { readChartColors } from "../../../src/features/stats/chartTheme"; import type { PathStats } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; const hour = 3_600_000; const fixture: PathStats = { since: hour / 2, until: 3 * hour, receptions: 10, hashed: 6, empty: 2, trace: 1, unclassified: 1, @@ -21,13 +22,29 @@ describe("path analytics charts", () => { }); it("fills zero-count length bins while preserving empty-path counts and visible single-hour points", () => { const c = readChartColors(); - const length = pathLengthOption(fixture.pathLengths, c); + const length = pathLengthOption(fixture.pathLengths, c, i18n.getFixedT("en")); expect(length).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); const option = length as { xAxis: { data: number[] }; series: { data: { value: number }[] }[] }; expect(option.xAxis.data).toHaveLength(64); expect(option.series[0]!.data[0]!.value).toBe(2); expect(option.series[0]!.data[1]!.value).toBe(0); expect(option.series[0]!.data.reduce((n, d) => n + d.value, 0)).toBe(8); - expect(pathTrendOption(pathHours(fixture), c)).toMatchObject({ animation: false, useUTC: true, legend: { top: 0 }, series: [{ connectNulls: false, showSymbol: true, data: [[0, 2], [hour, null], [2 * hour, 0]] }, { data: [[0, 3], [hour, null], [2 * hour, 0]] }, { data: [[0, 1], [hour, null], [2 * hour, 0]] }] }); + expect(pathTrendOption(pathHours(fixture), c, i18n.getFixedT("en"))).toMatchObject({ animation: false, useUTC: true, legend: { top: 0 }, series: [{ connectNulls: false, showSymbol: true, data: [[0, 2], [hour, null], [2 * hour, 0]] }, { data: [[0, 3], [hour, null], [2 * hour, 0]] }, { data: [[0, 1], [hour, null], [2 * hour, 0]] }] }); + }); + + it("translates chart descriptions and width labels without changing numerical data or gaps", () => { + const c = readChartColors(), hours = pathHours(fixture); + const en = i18n.getFixedT("en"), fr = i18n.getFixedT("fr"); + const before = pathTrendOption(hours, c, en), after = pathTrendOption(hours, c, fr); + expect(after).toMatchObject({ useUTC: true, aria: { label: { description: expect.stringContaining("lacunes") } }, series: [ + { name: "1 octet", connectNulls: false, data: [[0, 2], [hour, null], [2 * hour, 0]] }, + { name: "2 octets", data: [[0, 3], [hour, null], [2 * hour, 0]] }, + { name: "3 octets", data: [[0, 1], [hour, null], [2 * hour, 0]] }, + ] }); + const data = (option: typeof before) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data); + expect(data(after)).toEqual(data(before)); + const length = pathLengthOption(fixture.pathLengths, c, fr); + expect(length).toMatchObject({ xAxis: { name: "Entrées du chemin" }, series: [{ name: "Réceptions" }], aria: { label: { description: expect.stringContaining("Zéro") } } }); + expect(data(length)).toEqual(data(pathLengthOption(fixture.pathLengths, c, en))); }); }); diff --git a/tests/features/stats/scope-query.test.tsx b/tests/features/stats/scope-query.test.tsx index 50f72950..02b2b0f4 100644 --- a/tests/features/stats/scope-query.test.tsx +++ b/tests/features/stats/scope-query.test.tsx @@ -1,13 +1,16 @@ import { afterEach, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useScopes } from "../../../src/features/stats/useStats"; import { getStatsScopes } from "../../../src/api/client"; +import { ScopesTab } from "../../../src/features/stats/ScopesTab"; +import i18n from "../../../src/i18n"; const region = { iatas: ["YVR"] as string[] | undefined, regionKey: "YVR", isResolved: true }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); vi.mock("../../../src/api/client", () => ({ getStatsScopes: vi.fn(() => Promise.resolve([])) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); it("sends the selected IATAs and fetches a separate global query when the filter is cleared", async () => { @@ -27,3 +30,15 @@ it("does not fetch a global fallback for unresolved selected regions", () => { expect(getStatsScopes).not.toHaveBeenCalled(); unmount(); client.clear(); }); + +it("reuses the regional query without a time-window key when only the language changes", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No scope data available."); + expect(getStatsScopes).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune donnée de scope disponible.")).toBeInTheDocument(); + expect(getStatsScopes).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-scopes", "YVR"]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/scopes.test.ts b/tests/features/stats/scopes.test.ts index b74ade4b..b9a21937 100644 --- a/tests/features/stats/scopes.test.ts +++ b/tests/features/stats/scopes.test.ts @@ -1,12 +1,13 @@ import { expect, it } from "vitest"; import { scopeSummary, scopeChartOption } from "../../../src/features/stats/scopes"; import { readChartColors } from "../../../src/features/stats/chartTheme"; +import i18n from "../../../src/i18n"; it("totals memberships explicitly and bounds charts without dropping the remainder", () => { const rows = Array.from({ length: 20 }, (_, i) => ({ name: `#s${i}`, packetCount: i + 1, observerCount: 2, nodeCount: 1 })); const original = JSON.stringify(rows); expect(scopeSummary(rows)).toEqual({ active: 20, packets: 210, memberships: 40, nodes: 20 }); - const option = scopeChartOption(rows, "packetCount", readChartColors()); + const option = scopeChartOption(rows, "packetCount", readChartColors(), i18n.getFixedT("en")); expect(option).toMatchObject({ animation: false, tooltip: { renderMode: "richText" } }); expect(JSON.stringify(option)).toContain("Other scopes"); const bars = option as { series: { data: { value: number }[] }[] }; @@ -14,3 +15,22 @@ it("totals memberships explicitly and bounds charts without dropping the remaind expect(JSON.stringify(rows)).toBe(original); expect(scopeSummary([])).toEqual({ active: 0, packets: 0, memberships: 0, nodes: 0 }); }); + +it("translates only the remainder and description for every metric, preserving names, ranking and colors", () => { + const rows = Array.from({ length: 15 }, (_, i) => ({ name: `#scope${i}`, packetCount: i, observerCount: 15 - i, nodeCount: i % 3 })); + const before = JSON.stringify(rows), colors = readChartColors(); + for (const metric of ["packetCount", "observerCount", "nodeCount"] as const) { + const en = scopeChartOption(rows, metric, colors, i18n.getFixedT("en")); + const fr = scopeChartOption(rows, metric, colors, i18n.getFixedT("fr")); + const values = (option: typeof fr) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data); + expect(values(fr)).toEqual(values(en)); + const labels = (option: typeof fr) => (option.yAxis as { data: string[] }).data; + expect(labels(fr).slice(0, 12)).toEqual(labels(en).slice(0, 12)); + expect(labels(en).at(-1)).toBe("Other scopes"); + expect(labels(fr).at(-1)).toBe("Autres scopes"); + expect(fr).toMatchObject({ aria: { label: { description: expect.stringContaining("valeurs exactes") } } }); + const bars = fr as { series: { data: { value: number }[] }[] }; + expect(bars.series[0]!.data.reduce((sum, row) => sum + row.value, 0)).toBe(rows.reduce((sum, row) => sum + row[metric], 0)); + } + expect(JSON.stringify(rows)).toBe(before); +}); diff --git a/tests/features/stats/signal-query.test.tsx b/tests/features/stats/signal-query.test.tsx index cf443a2c..476605d7 100644 --- a/tests/features/stats/signal-query.test.tsx +++ b/tests/features/stats/signal-query.test.tsx @@ -1,14 +1,17 @@ import { afterEach, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useSignalStats } from "../../../src/features/stats/useSignalStats"; import { getSignalStats } from "../../../src/api/client"; import type { StatsRange } from "../../../src/features/stats/types"; +import { SignalTab } from "../../../src/features/stats/SignalTab"; +import i18n from "../../../src/i18n"; const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); vi.mock("../../../src/api/client", () => ({ getSignalStats: vi.fn(() => new Promise(() => {})) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); it("blocks unresolved regions and then uses bounded minute windows and cancellation", async () => { @@ -31,3 +34,17 @@ it("blocks unresolved regions and then uses bounded minute windows and cancellat expect(second[2]).toEqual(["YOW"]); unmount(); expect(second[3]?.aborted).toBe(true); client.clear(); }); + +it("keeps the cached query and time window when only the display language changes", async () => { + vi.mocked(getSignalStats).mockResolvedValueOnce({ since: 0, until: 3_600_000, receptions: 0, + snr: { samples: 0, average: null, histogram: [] }, rssi: { samples: 0, average: null, histogram: [] }, hourly: [] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No retained receptions in this window."); + expect(getSignalStats).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument(); + expect(getSignalStats).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-signal", "YVR", "24h"]]); + unmount(); client.clear(); +}); diff --git a/tests/features/stats/signal.test.ts b/tests/features/stats/signal.test.ts index 0cb27ec8..0e2288b6 100644 --- a/tests/features/stats/signal.test.ts +++ b/tests/features/stats/signal.test.ts @@ -1,7 +1,11 @@ import { describe, expect, it } from "vitest"; -import { signalHours, signalBinLabel, signalHistogramOption, signalTrendOption } from "../../../src/features/stats/signal"; +import { signalHours, signalBinLabel, signalHistogramOption, signalTrendOption, signalCoverageOption } from "../../../src/features/stats/signal"; import { readChartColors } from "../../../src/features/stats/chartTheme"; import type { SignalStats } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; + +const en = i18n.getFixedT("en"); +const fr = i18n.getFixedT("fr"); const hour = 3_600_000; export const signalFixture: SignalStats = { @@ -27,11 +31,30 @@ describe("signal analytics", () => { expect(signalHours({ ...signalFixture, until: 30 * 24 * hour + hour / 2, hourly: [] })).toHaveLength(721); }); it("labels exact half-open bins including overflow without assigning quality ratings", () => { - expect(signalBinLabel({ lower: null, upper: -30, count: 2 })).toBe("< -30"); - expect(signalBinLabel({ lower: -30, upper: -25, count: 2 })).toBe("-30 to < -25"); - expect(signalBinLabel({ lower: 30, upper: null, count: 2 })).toBe("≥ 30"); + expect(signalBinLabel({ lower: null, upper: -30, count: 2 }, en)).toBe("< -30"); + expect(signalBinLabel({ lower: -30, upper: -25, count: 2 }, en)).toBe("-30 to < -25"); + expect(signalBinLabel({ lower: 30, upper: null, count: 2 }, en)).toBe("≥ 30"); const colors = readChartColors(); - expect(signalHistogramOption(signalFixture.snr, "SNR", "dB", colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); - expect(signalTrendOption(signalHours(signalFixture), "snr", colors)).toMatchObject({ animation: false, series: [{ connectNulls: false, showSymbol: true, data: [[0, 0], [hour, null], [2 * hour, 0], [3 * hour, null]] }] }); + expect(signalHistogramOption(signalFixture.snr, "SNR", "dB", colors, en)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, aria: { enabled: true } }); + expect(signalTrendOption(signalHours(signalFixture), "snr", colors, en)).toMatchObject({ animation: false, series: [{ connectNulls: false, showSymbol: true, data: [[0, 0], [hour, null], [2 * hour, 0], [3 * hour, null]] }] }); + }); + + it("translates plotted text without changing bin boundaries, coverage counts or UTC gaps", () => { + const before = JSON.stringify(signalFixture), colors = readChartColors(); + expect(signalHistogramOption(signalFixture.snr, "SNR", "dB", colors, fr)).toMatchObject({ + tooltip: { renderMode: "richText" }, + aria: { label: { description: expect.stringContaining("Distribution du SNR en dB") } }, + xAxis: { data: ["< -30 dB", "-30 à < -25 dB", "≥ 30 dB"] }, + series: [{ name: "Échantillons SNR", data: [{ value: 2 }, { value: 78 }, { value: 0 }] }], + }); + expect(signalCoverageOption(signalFixture, colors, fr)).toMatchObject({ + series: [{ name: "Disponibles", data: [80, 90] }, { name: "Indisponibles", data: [20, 10] }], + }); + const trend = signalTrendOption(signalHours(signalFixture), "snr", colors, fr); + expect(trend).toMatchObject({ useUTC: true, series: [{ connectNulls: false, data: [[0, 0], [hour, null], [2 * hour, 0], [3 * hour, null]] }] }); + const tooltip = trend.tooltip as { valueFormatter: (value: unknown) => string }; + expect(tooltip.valueFormatter(null)).toBe("Aucun échantillon"); + expect(tooltip.valueFormatter(0)).toBe("0.00 dB"); + expect(JSON.stringify(signalFixture)).toBe(before); }); }); diff --git a/tests/features/stats/traffic-query.test.tsx b/tests/features/stats/traffic-query.test.tsx index e07057a8..aa698aac 100644 --- a/tests/features/stats/traffic-query.test.tsx +++ b/tests/features/stats/traffic-query.test.tsx @@ -1,13 +1,16 @@ import { afterEach, expect, it, vi } from "vitest"; -import { renderHook, waitFor } from "@testing-library/react"; +import { act, render, renderHook, screen, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { useStatsObservations } from "../../../src/features/stats/useStats"; import { getStatsObservations } from "../../../src/api/client"; +import { TrafficTab } from "../../../src/features/stats/TrafficTab"; +import i18n from "../../../src/i18n"; const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); vi.mock("../../../src/api/client", () => ({ getStatsObservations: vi.fn(() => new Promise(() => {})) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: () =>
})); afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; }); it("waits for a selected region to resolve instead of making an unfiltered request", async () => { @@ -19,6 +22,19 @@ it("waits for a selected region to resolve instead of making an unfiltered reque unmount(); client.clear(); }); +it("keeps the same traffic cache entry and request when only the language changes", async () => { + vi.mocked(getStatsObservations).mockResolvedValueOnce([]); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { unmount } = render(); + await screen.findByText("No retained receptions in this window."); + expect(getStatsObservations).toHaveBeenCalledOnce(); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Aucune réception conservée dans cette période.")).toBeInTheDocument(); + expect(getStatsObservations).toHaveBeenCalledOnce(); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-observations", "YVR", "7d"]]); + unmount(); client.clear(); +}); + it("cancels the old region request and uses a new cache key when filters change", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const { rerender, unmount } = renderHook(() => useStatsObservations("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); diff --git a/tests/features/stats/traffic.test.ts b/tests/features/stats/traffic.test.ts index 583f000f..ffc430e1 100644 --- a/tests/features/stats/traffic.test.ts +++ b/tests/features/stats/traffic.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it } from "vitest"; -import { trafficModel, trafficHeatmapOption, trafficTrendOption } from "../../../src/features/stats/traffic"; +import { trafficAreaLabel, trafficModel, trafficHeatmapOption, trafficTrendOption } from "../../../src/features/stats/traffic"; import { readChartColors } from "../../../src/features/stats/chartTheme"; import type { ObservationPoint } from "../../../src/features/stats/types"; +import i18n from "../../../src/i18n"; const hour = 3_600_000; const now = Date.UTC(2026, 8, 19, 12, 30); @@ -48,7 +49,28 @@ describe("traffic exploration", () => { expect(model.hours.every((point) => point.total === null)).toBe(true); const populated = trafficModel([point(end, "YOW", 1)], "24h", now); const colors = readChartColors(); - expect(trafficHeatmapOption(populated, colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, visualMap: { min: 0, max: 1 } }); - expect(trafficTrendOption(populated, colors)).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, series: [{ connectNulls: false }] }); + expect(trafficHeatmapOption(populated, colors, i18n.getFixedT("en"))).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, visualMap: { min: 0, max: 1 } }); + expect(trafficTrendOption(populated, colors, i18n.getFixedT("en"))).toMatchObject({ animation: false, tooltip: { renderMode: "richText" }, series: [{ connectNulls: false }] }); + }); + + it("translates display labels and plural tooltips without changing the model, UTC cells or gaps", () => { + const model = trafficModel([point(end - 2 * hour, "YOW", 0), point(end, "YOW", 3), point(end, "", 1)], "24h", now); + const before = JSON.stringify(model), colors = readChartColors(), en = i18n.getFixedT("en"), fr = i18n.getFixedT("fr"); + expect(trafficAreaLabel("YOW", fr)).toBe("YOW"); + expect(trafficAreaLabel("Unassigned", fr)).toBe("Non attribué"); + expect(trafficAreaLabel("Other IATAs", fr)).toBe("Autres IATA"); + const trend = trafficTrendOption(model, colors, fr); + expect(trend).toMatchObject({ useUTC: true, series: [{ name: "YOW", connectNulls: false }, { name: "Non attribué" }], aria: { label: { description: expect.stringContaining("lacunes") } } }); + const chartData = (option: typeof trend) => (Array.isArray(option.series) ? option.series : [option.series]).map((series) => series?.data); + expect(chartData(trend)).toEqual(chartData(trafficTrendOption(model, colors, en))); + expect(model.hours.slice(-3).map((row) => row.total)).toEqual([0, null, 4]); + const heatmap = trafficHeatmapOption(model, colors, fr); + expect(heatmap).toMatchObject({ visualMap: { text: ["Plus", "Moins"], min: 0, max: 4 }, series: [{ name: "Réceptions", data: model.heatmap }] }); + expect(chartData(heatmap)).toEqual(chartData(trafficHeatmapOption(model, colors, en))); + const tooltip = (option: typeof heatmap) => (option as { tooltip: { formatter: (item: { value: number[] }) => string } }).tooltip.formatter; + expect(tooltip(heatmap)({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 réception"); + expect(tooltip(heatmap)({ value: [12, 1, 1200] })).toBe(`2026-09-19 12:00 UTC\n${(1200).toLocaleString()} réceptions`); + expect(tooltip(trafficHeatmapOption(model, colors, en))({ value: [12, 1, 1] })).toBe("2026-09-19 12:00 UTC\n1 reception"); + expect(JSON.stringify(model)).toBe(before); }); }); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index e03bb640..1064263d 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -111,6 +111,16 @@ describe("formatPropagation", () => { }); describe("formatClockDrift", () => { + it("accepts translated direction labels without changing signs, magnitude or rounding", () => { + const labels = { inSync: "synchronisé", ahead: "en avance", behind: "en retard" }; + expect(formatClockDrift(0, labels)).toBe("synchronisé"); + expect(formatClockDrift(42, labels)).toBe("+42s en avance"); + expect(formatClockDrift(-45, labels)).toBe("-45s en retard"); + expect(formatClockDrift(432, labels)).toBe("+7m 12s en avance"); + expect(formatClockDrift(-3670, labels)).toBe("-1h 1m en retard"); + expect(formatClockDrift(3600, labels)).toBe("+1h 0m en avance"); + }); + it("labels a zero drift as in sync", () => { expect(formatClockDrift(0)).toBe("in sync"); }); From dce4e49900c081ba00f5c3b53e32680a5c83b104 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 25 Sep 2026 11:21:07 -0400 Subject: [PATCH 111/163] fix(stats): hide unavailable Talkers results (#68) --- src/features/stats/TalkersTab.tsx | 13 ++- tests/features/stats/TalkersTab.test.tsx | 120 +++++++++++++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) create mode 100644 tests/features/stats/TalkersTab.test.tsx diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 0ef75873..6caf0f19 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -26,7 +26,10 @@ export function TalkersTab({ range }: TalkersTabProps) { const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const advertisers = topAdvertisers.data ?? []; + const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isLoading || topAdvertisers.isPlaceholderData; + const talkersLoading = topTalkers.isPending || topTalkers.isLoading || topTalkers.isPlaceholderData; + const talkersUnavailable = talkersLoading || topTalkers.isError; + const advertisers = advertisersLoading || topAdvertisers.isError ? [] : (topAdvertisers.data ?? []); const advertiserColumns = useMemo[]>(() => { const windowMs = RANGE_MS[range]; @@ -56,8 +59,8 @@ export function TalkersTab({ range }: TalkersTabProps) { }, [range]); const talkerRows = useMemo( - () => (topTalkers.data ?? []).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), - [topTalkers.data, colors], + () => (talkersUnavailable ? [] : (topTalkers.data ?? [])).map((t) => ({ name: t.senderName, value: t.messageCount, color: colors.secondary })), + [topTalkers.data, colors, talkersUnavailable], ); const talkersOption = useMemo(() => leaderboardOption(talkerRows, colors), [talkerRows, colors]); @@ -71,7 +74,7 @@ export function TalkersTab({ range }: TalkersTabProps) { rowKey={(a) => a.nodeId} selectedKey={null} onSelect={() => {}} - isLoading={topAdvertisers.isLoading} + isLoading={advertisersLoading} emptyLabel={topAdvertisers.isError ? "Failed to load" : "No advertisers"} />
@@ -81,7 +84,7 @@ export function TalkersTab({ range }: TalkersTabProps) { right={by name} height={leaderboardHeight(talkerRows.length)} option={talkersOption} - isLoading={topTalkers.isLoading} + isLoading={talkersLoading} isError={topTalkers.isError} isEmpty={talkerRows.length === 0} /> diff --git a/tests/features/stats/TalkersTab.test.tsx b/tests/features/stats/TalkersTab.test.tsx new file mode 100644 index 00000000..f983ba72 --- /dev/null +++ b/tests/features/stats/TalkersTab.test.tsx @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { TalkersTab } from "../../../src/features/stats/TalkersTab"; +import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client"; +import type { StatsRange, TopAdvertiser, TopTalker } from "../../../src/features/stats/types"; +import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; + +const region = { iatas: ["YVR"], regionKey: "YVR" }; +const advertisers: TopAdvertiser[] = [{ nodeId: "fixture-node", nodeName: "Old advertiser", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", advertCount: 14, floodAdvertCount: 10, directAdvertCount: 4, lastHeard: 0 }]; +const talkers: TopTalker[] = [{ senderName: "Old sender", messageCount: 42, lastSent: 0 }]; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/api/client", () => ({ getTopAdvertisers: vi.fn(), getTopTalkers: vi.fn() })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: EChartsOption }) => { + const axis = Array.isArray(option.yAxis) ? option.yAxis[0] : option.yAxis; + return
{axis?.data?.map(String).join(" ")}
; +} })); + +const clients: QueryClient[] = []; +beforeEach(() => { + vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; + vi.mocked(getTopAdvertisers).mockReset().mockResolvedValue(advertisers); + vi.mocked(getTopTalkers).mockReset().mockResolvedValue(talkers); +}); +afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +function mount() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + const view = (range: StatsRange) => ; + const result = render(view("24h")); + return { client, rerender: (range: StatsRange) => result.rerender(view(range)) }; +} + +it.each(["region", "range"] as const)("hides previous results while the new %s is pending, then shows each resolved panel", async (filter) => { + const { rerender } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + const nextAdvertisers = deferred(), nextTalkers = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(nextAdvertisers.promise); + vi.mocked(getTopTalkers).mockReturnValue(nextTalkers.promise); + if (filter === "region") { region.iatas = ["YOW"]; region.regionKey = "YOW"; } + const requestedAt = Date.now(); + rerender(filter === "range" ? "7d" : "24h"); + await waitFor(() => expect(getTopAdvertisers).toHaveBeenCalledTimes(2)); + expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); + expect(screen.queryByText("Old sender")).not.toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => nextAdvertisers.resolve([{ ...advertisers[0]!, nodeName: "New advertiser" }])); + await screen.findByText("New advertiser"); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => nextTalkers.resolve([{ ...talkers[0]!, senderName: "New sender" }])); + await screen.findByText("New sender"); + const request = vi.mocked(getTopAdvertisers).mock.calls[1]!; + expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]); + expect(request[2]).toBe(20); + const windowMs = (filter === "range" ? 7 : 1) * 86_400_000; + expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs); + expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs); +}); + +it("shows an advertiser refetch error instead of cached rows, keeps the sender panel, and recovers", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + vi.mocked(getTopAdvertisers).mockRejectedValue(new Error("controlled advertiser failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); + await screen.findByText("Failed to load"); + expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument(); + expect(screen.getByText("Old sender")).toBeInTheDocument(); + expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers); + vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]); + await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] })); + await screen.findByText("Recovered advertiser"); + expect(screen.queryByText("Failed to load")).not.toBeInTheDocument(); + expect(getTopTalkers).toHaveBeenCalledOnce(); +}); + +it("keeps advertisers usable when only the sender request fails", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + vi.mocked(getTopTalkers).mockRejectedValue(new Error("controlled sender failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-top-talkers"] })); + await screen.findByText("Failed to load"); + expect(screen.queryByText("Old sender")).not.toBeInTheDocument(); + expect(screen.getByText("Old advertiser")).toBeInTheDocument(); + expect(getTopAdvertisers).toHaveBeenCalledOnce(); +}); + +it("retains valid rows and rates during an ordinary background refresh", async () => { + const { client } = mount(); + await screen.findByText("Old advertiser"); await screen.findByText("Old sender"); + const next = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(next.promise); + let refresh!: Promise; + await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); }); + expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching"); + expect(screen.getByText("Old advertiser")).toBeInTheDocument(); + expect(screen.getByText("10/d")).toBeInTheDocument(); + expect(screen.getByText("4/d")).toBeInTheDocument(); + expect(screen.getByText("Old sender")).toBeInTheDocument(); + await act(async () => { next.resolve([{ ...advertisers[0]!, nodeName: "Fresh advertiser" }]); await refresh; }); + await screen.findByText("Fresh advertiser"); +}); + +it("distinguishes initial loading from successful empty results", async () => { + const nextAdvertisers = deferred(), nextTalkers = deferred(); + vi.mocked(getTopAdvertisers).mockReturnValue(nextAdvertisers.promise); + vi.mocked(getTopTalkers).mockReturnValue(nextTalkers.promise); + mount(); + expect(screen.queryByText("No advertisers")).not.toBeInTheDocument(); + expect(screen.queryByTestId("leaderboard")).not.toBeInTheDocument(); + await act(() => { nextAdvertisers.resolve([]); nextTalkers.resolve([]); }); + await screen.findByText("No advertisers"); + expect(screen.getByText("No data")).toBeInTheDocument(); +}); From 9cd2e8f6db494a0bc47922c5e0d1ec5a531a1fac Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 25 Sep 2026 11:21:29 -0400 Subject: [PATCH 112/163] fix(stats): hide unavailable Mesh overview data (#72) --- src/features/stats/MeshTab.tsx | 67 ++++++----- tests/features/stats/MeshTab.test.tsx | 155 ++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 27 deletions(-) create mode 100644 tests/features/stats/MeshTab.test.tsx diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 84d33c86..c601641f 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -9,6 +9,11 @@ import { aggregatePresets, formatPreset, payloadBarItems } from "./transforms"; import type { WsManager } from "../../api/ws-manager"; import type { ObservationPoint, StatsRange } from "./types"; +// Keep unavailable values in the cache, but do not display them under the current filters. +function readyData(query: { data: T | undefined; isSuccess: boolean; isPlaceholderData: boolean }) { + return query.isSuccess && !query.isPlaceholderData ? query.data : undefined; +} + // The observations endpoint returns one row per hour+iata; collapse to one row per hour (a no-op for a // single selected region). uniquePackets / activeObservers summed across iatas are approximate. function aggregateByHour(points: ObservationPoint[]) { @@ -44,30 +49,40 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const scopes = useScopes(); const nodeTypes = useNodeTypes(); - const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]); + const ov = readyData(overview); + const observationsData = readyData(observations); + const overviewObsData = readyData(overviewObs); + const payloadData = readyData(payload); + const topNodesData = readyData(topNodes); + const topObserversData = readyData(topObservers); + const radioPresetsData = readyData(radioPresets); + const scopesData = readyData(scopes); + const nodeTypesData = readyData(nodeTypes); + + const obs = useMemo(() => aggregateByHour(observationsData ?? []), [observationsData]); const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]); const nodeRows = useMemo( () => - (topNodes.data ?? []).map((n) => ({ + (topNodesData ?? []).map((n) => ({ name: n.nodeName ?? n.nodeId.slice(0, 8), value: n.observationCount, color: nodeTypeColor(n.nodeTypeName, colors), })), - [topNodes.data, colors], + [topNodesData, colors], ); const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]); - const payloadItems = useMemo(() => payloadBarItems(payload.data ?? []), [payload.data]); + const payloadItems = useMemo(() => payloadBarItems(payloadData ?? []), [payloadData]); const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]); const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const observerRows = useMemo( - () => (topObservers.data ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), - [topObservers.data, colors], + () => (topObserversData ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })), + [topObserversData, colors], ); const observersOption = useMemo(() => leaderboardOption(observerRows, colors), [observerRows, colors]); - const observerIds = useMemo(() => (topObservers.data ?? []).map((o) => o.observerId), [topObservers.data]); + const observerIds = useMemo(() => (topObserversData ?? []).map((o) => o.observerId), [topObserversData]); const observerEvents = useMemo( () => ({ click: (params: unknown) => { @@ -80,68 +95,66 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const typeRows = useMemo( () => - [...(nodeTypes.data ?? [])] + [...(nodeTypesData ?? [])] .sort((a, b) => b.count - a.count) .map((t) => ({ name: t.nodeTypeName, value: t.count, color: nodeTypeColor(t.nodeTypeName, colors) })), - [nodeTypes.data, colors], + [nodeTypesData, colors], ); const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]); const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]); const presetRows = useMemo( - () => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), - [radioPresets.data], + () => aggregatePresets(radioPresetsData ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), + [radioPresetsData], ); const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [presetRows, colors]); const scopeRows = useMemo( - () => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount), - [scopes.data], + () => [...(scopesData ?? [])].sort((a, b) => b.packetCount - a.packetCount), + [scopesData], ); - const kpiObs = useMemo(() => aggregateByHour(overviewObs.data ?? []), [overviewObs.data]); + const kpiObs = useMemo(() => aggregateByHour(overviewObsData ?? []), [overviewObsData]); const obsSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.observationCount), [kpiObs]); const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]); - const ov = overview.data; - const kpiLoading = overview.isLoading; // top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below const ovWindow = `${ov?.windowHours ?? 24}h`; return (
- - - - + + + +
Observations · {range}} height={200} option={obsOption} - isLoading={observations.isLoading} + isLoading={observations.isPending || observations.isPlaceholderData} isError={observations.isError} isEmpty={obs.length === 0} />
{/* range-driven charts lead the grid; the all-time ones follow below */} - Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> + Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> Payload types · {range}} - right={{formatCount(payloadTotal)} obs} + right={{formatCount(payloadData === undefined ? undefined : payloadTotal)} obs} height={208} option={payloadOption} - isLoading={payload.isLoading} + isLoading={payload.isPending || payload.isPlaceholderData} isError={payload.isError} isEmpty={payloadItems.length === 0} /> {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} - - - + + + Scopes · selected region · retained data}> {scopes.isError ? ( diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx new file mode 100644 index 00000000..20d783a4 --- /dev/null +++ b/tests/features/stats/MeshTab.test.tsx @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MeshTab } from "../../../src/features/stats/MeshTab"; +import { getStatsOverview, getStatsObservations, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; +import type { WsManager } from "../../../src/api/ws-manager"; +import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; +import type { StatsOverview, StatsRange } from "../../../src/features/stats/types"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +const overview = { totalPackets: 111, totalObservations: 222, activeObservers: 3, activeIatas: 4, windowHours: 24 }; +const points = [1, 2].map((hour) => ({ hour: hour * 3_600_000, iata: "YVR", observationCount: hour * 10, uniquePackets: hour, activeObservers: hour })); +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/features/stats/useLiveStats", () => ({ useLiveOverview: vi.fn() })); +vi.mock("../../../src/api/client", () => ({ getStatsOverview: vi.fn(), getStatsObservations: vi.fn(), getPayloadBreakdown: vi.fn(), getTopNodes: vi.fn(), getTopObservers: vi.fn(), getRadioPresets: vi.fn(), getStatsScopes: vi.fn(), getStatsNodeTypes: vi.fn() })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option, onEvents }: { option: EChartsOption; onEvents?: Record void> }) => })); + +const clients: QueryClient[] = []; +beforeEach(() => { + vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; + vi.mocked(getStatsOverview).mockReset().mockResolvedValue(overview); + vi.mocked(getStatsObservations).mockReset().mockResolvedValue(points); + vi.mocked(getPayloadBreakdown).mockReset().mockResolvedValue([{ payloadType: 4, payloadTypeName: "ADVERT", count: 17 }]); + vi.mocked(getTopNodes).mockReset().mockResolvedValue([{ nodeId: "node-id", nodeName: "Old node", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", observationCount: 15, lastHeard: 0 }]); + vi.mocked(getTopObservers).mockReset().mockResolvedValue([{ observerId: "observer-id", displayName: "Old observer", observerType: null, iata: "YVR", observationCount: 16 }]); + vi.mocked(getRadioPresets).mockReset().mockResolvedValue([{ preset: "910.525,62.5,7", iata: "YVR", sourceType: "node", count: 18 }]); + vi.mocked(getStatsScopes).mockReset().mockResolvedValue([{ name: "#old", packetCount: 19, observerCount: 2, nodeCount: 1 }]); + vi.mocked(getStatsNodeTypes).mockReset().mockResolvedValue([{ nodeType: 2, nodeTypeName: "Repeater", count: 20 }]); +}); +afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); + +function card(title: string) { return screen.getByText(title).parentElement!.parentElement!; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +function mount(range: StatsRange = "24h") { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + const onSelectObserver = vi.fn(); + const view = (range: StatsRange) => ; + const result = render(view(range)); + return { client, onSelectObserver, container: result.container, rerender: (next: StatsRange) => result.rerender(view(next)) }; +} +async function loaded() { await waitFor(() => expect(screen.getAllByTestId("chart")).toHaveLength(6)); await screen.findByText("#old"); } + +it("hides all previous-region values and sparklines until their own requests resolve", async () => { + const { rerender, container } = mount(); await loaded(); + const next = deferred(); + vi.mocked(getStatsOverview).mockReturnValue(next.promise); + vi.mocked(getStatsObservations).mockImplementation(() => new Promise(() => {})); + vi.mocked(getPayloadBreakdown).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopNodes).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopObservers).mockImplementation(() => new Promise(() => {})); + vi.mocked(getRadioPresets).mockImplementation(() => new Promise(() => {})); + vi.mocked(getStatsScopes).mockImplementation(() => new Promise(() => {})); + vi.mocked(getStatsNodeTypes).mockImplementation(() => new Promise(() => {})); + region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender("24h"); + await waitFor(() => expect(getStatsOverview).toHaveBeenCalledWith(["YOW"])); + expect(within(card("Total packets")).queryByText("111")).not.toBeInTheDocument(); + expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(container.querySelectorAll("polyline")).toHaveLength(0); + expect(screen.queryByText("#old")).not.toBeInTheDocument(); + expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); + await act(() => next.resolve({ ...overview, totalPackets: 333 })); + await within(card("Total packets")).findByText("333"); + expect(screen.queryAllByTestId("chart")).toHaveLength(0); +}); + +it("hides pending range charts while retaining fixed-24h KPIs, sparklines and population panels", async () => { + const { rerender, container } = mount(); await loaded(); + vi.mocked(getStatsObservations).mockImplementation(() => new Promise(() => {})); + vi.mocked(getPayloadBreakdown).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopObservers).mockImplementation(() => new Promise(() => {})); + rerender("7d"); + expect(within(card("Observations · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(within(card("Top observers · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(within(card("Payload types · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(3); + expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); + expect(container.querySelectorAll("polyline")).toHaveLength(2); + expect(screen.getByText("#old")).toBeInTheDocument(); + expect(getStatsOverview).toHaveBeenCalledOnce(); + expect(getStatsObservations).toHaveBeenCalledTimes(2); + expect(getTopNodes).toHaveBeenCalledOnce(); +}); + +it("hides failed overview values without discarding the cache or healthy panels, then recovers", async () => { + const { client } = mount(); await loaded(); + vi.mocked(getStatsOverview).mockRejectedValue(new Error("controlled overview failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-overview"] })); + await waitFor(() => expect(within(card("Total packets")).queryByText("111")).not.toBeInTheDocument()); + expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); + expect(client.getQueryData(["stats-overview", "YVR"])).toEqual(overview); + expect(screen.getAllByTestId("chart")).toHaveLength(6); + vi.mocked(getStatsOverview).mockResolvedValue({ ...overview, totalPackets: 444 }); + await act(() => client.refetchQueries({ queryKey: ["stats-overview"] })); + await within(card("Total packets")).findByText("444"); +}); + +it("hides only failed 24h sparklines while the selected-range chart and KPI values remain usable", async () => { + const { client, container } = mount("7d"); await loaded(); + expect(container.querySelectorAll("polyline")).toHaveLength(2); + vi.mocked(getStatsObservations).mockRejectedValue(new Error("controlled sparkline failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-observations", "YVR", "24h"], exact: true })); + await waitFor(() => expect(container.querySelectorAll("polyline")).toHaveLength(0)); + expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); + expect(within(card("Observations · 7d")).getByTestId("chart")).toBeInTheDocument(); + expect(client.getQueryData(["stats-observations", "YVR", "24h"])).toEqual(points); +}); + +it("does not report a cached payload total after a failed refresh", async () => { + const { client } = mount(); await loaded(); + vi.mocked(getPayloadBreakdown).mockRejectedValue(new Error("controlled payload failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-payload"] })); + await within(card("Payload types · 24h")).findByText("Failed to load"); + expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); + expect(screen.getByText("— obs")).toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(5); +}); + +it("retains valid values, series, sparklines and observer navigation during same-key background refreshes", async () => { + const { client, container, onSelectObserver } = mount(); await loaded(); + const next = deferred(); vi.mocked(getStatsOverview).mockReturnValue(next.promise); + let refresh!: Promise; + await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-overview"] }); }); + expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(6); + expect(container.querySelectorAll("polyline")).toHaveLength(2); + fireEvent.click(within(card("Top observers · 24h")).getByTestId("chart")); + expect(onSelectObserver).toHaveBeenCalledWith("observer-id"); + await act(async () => { next.resolve({ ...overview, totalPackets: 555 }); await refresh; }); + await within(card("Total packets")).findByText("555"); +}); + +it("distinguishes initial loading from successful empty results and zero totals", async () => { + const next = deferred(); vi.mocked(getStatsOverview).mockReturnValue(next.promise); + vi.mocked(getStatsObservations).mockResolvedValue([]); + vi.mocked(getPayloadBreakdown).mockResolvedValue([]); + vi.mocked(getTopNodes).mockResolvedValue([]); + vi.mocked(getTopObservers).mockResolvedValue([]); + vi.mocked(getRadioPresets).mockResolvedValue([]); + vi.mocked(getStatsScopes).mockResolvedValue([]); + vi.mocked(getStatsNodeTypes).mockResolvedValue([]); + mount(); + expect(screen.queryByText("0 obs")).not.toBeInTheDocument(); + expect(screen.queryByText("No data")).not.toBeInTheDocument(); + expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); + await act(() => next.resolve({ totalPackets: 0, totalObservations: 0, activeObservers: 0, activeIatas: 0, windowHours: 24 })); + await screen.findByText("0 obs"); + expect(screen.getAllByText("No data")).toHaveLength(7); + expect(within(card("Total packets")).getByText("0")).toBeInTheDocument(); +}); From 54b5093ac0302c7db9d51e1d7fe23570eae7cdb5 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:37:57 -0400 Subject: [PATCH 113/163] fix(packets): fold summaries into the endpoints cell (#73) --- src/features/packets/PacketEndpoints.tsx | 21 +++++++++++++------ src/features/packets/PacketRow.tsx | 2 +- src/features/packets/PacketTableRow.tsx | 1 - .../features/packets/PacketEndpoints.test.tsx | 20 ++++++++++++++++++ .../features/packets/PacketTableRow.test.tsx | 6 +++--- 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 33e5822d..69788392 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,5 +1,5 @@ import type { PacketSummary } from "../../types/api"; -import type { PathConfidence } from "../../types/enums"; +import { PayloadType, type PathConfidence } from "../../types/enums"; import { buildPathSummary, type PathChip } from "./path-summary"; // Same three-state vocabulary PathData uses in the analyzer. @@ -21,7 +21,7 @@ function Chip({ chip }: { chip: PathChip }) { ); } return ( - + {chip.label} ); @@ -30,12 +30,21 @@ function Chip({ chip }: { chip: PathChip }) { const Na = () => n/a; // The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them -// nil on the REST list, so scrollback rows read n/a — as do payload types with no addressed -// endpoint at all (GRP_TXT/GRP_DATA/TRACE). +// nil on the REST list; there the server's summary (advert name, trace tag, ack checksum) stands in. export function PacketEndpoints({ packet }: { packet: PacketSummary }) { const { source, destination } = buildPathSummary(packet); - // One n/a for the pair reads better than "n/a → n/a" on every historical row. - if (!source && !destination) return ; + + // An advert is the node announcing itself, so its name is the whole story — no destination. + if (packet.payloadType === PayloadType.ADVERT) { + const self = source ?? (packet.summary ? { kind: "node" as const, label: packet.summary, confidence: "high" as const } : null); + return self ? : ; + } + + if (!source && !destination) { + return packet.summary + ? {packet.summary} + : ; + } return ( diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index 2fcba531..0ee94913 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -54,7 +54,7 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp
- {packet.summary &&
{packet.summary}
} + {packet.summary &&
{packet.summary}
}
diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 20339037..95a430f2 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -58,7 +58,6 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa {pathLength?.hopCount ?? na} {pathLength?.hashSize ?? na} - {packet.summary && {packet.summary}} diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 037489cc..49d0ef21 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -64,4 +64,24 @@ describe("PacketEndpoints", () => { }))} />); expect(screen.getByText("?")).toBeInTheDocument(); }); + + it("shows an advert as its single source node with no destination", () => { + const advert = { ...pkt(obs({ resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "Fuzz HQ" }] } })), payloadType: 4, summary: "Fuzz HQ" }; + render(); + expect(screen.getAllByText("Fuzz HQ")).toHaveLength(1); + expect(screen.queryByText("→")).not.toBeInTheDocument(); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + }); + + it("falls back to the summary name for an advert with no resolved source", () => { + render(); + expect(screen.getByText("Fuzz HQ").className).toContain("text-green"); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + }); + + it("replaces n/a with the summary when there are no endpoints", () => { + render(); + expect(screen.getByText("TRACE 2ca2a79c").className).toContain("text-text-muted"); + expect(screen.queryByText("n/a")).not.toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 2e114856..6833393c 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -23,9 +23,9 @@ const observer = ( }; describe("PacketTableRow", () => { - it("keeps endpoints visible alongside a payload summary", () => { - render( {}} />); - expect(screen.getByText("Packet summary")).toBeInTheDocument(); + it("prefers endpoints over a payload summary", () => { + render( {}} />); + expect(screen.queryByText("Packet summary")).not.toBeInTheDocument(); expect(screen.getByText("Source node")).toBeInTheDocument(); expect(screen.getByText("Destination node")).toBeInTheDocument(); }); From 17f48fb932facbdcc45068afd2db6036ac2ec94d Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:38:55 -0400 Subject: [PATCH 114/163] chore(types): mirror ws opt-in fields from the server (#91) --- src/types/ws.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/types/ws.ts b/src/types/ws.ts index d2cbc3b0..eafdced8 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -35,6 +35,9 @@ export interface WsConfigured { type: "configured"; id: string; resolvePath: boolean; + // echoed by servers that support these opt-ins; we never set them + includeObserverKey?: boolean; + includeRepeats?: boolean; } export interface WsPacketObservation { @@ -52,10 +55,13 @@ export interface WsPacketObservation { observationCount: number; scope?: string; // matched transport scope name; omitted when none matched summary?: string; + // later hearing over a new path, only sent to configure{includeRepeats}; observationCount is 0 + isRepeat?: boolean; }; observation: { observerId: string; observerName: string; + observerPublicKey?: string; // only with configure{includeObserverKey} iata: string; heardAt: number; rssi: number; From d259be512efc225bf48ddb166b51b84f5f1832fe Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 30 Sep 2026 08:03:13 -0400 Subject: [PATCH 115/163] feat: observer monitoring, retained evidence and channel scopes (#75-#99) Lands the stacked web PRs #75, #79, #80, #81, #83, #85, #87, #89, #92, #95 and #99 in one squash. Server counterparts #166-#176 are merged. Remaining review items follow in a fix PR. --- docs/translations.md | 9 +- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 229 +++++---- src/api/client.ts | 27 +- src/components/CopyLinkButton.tsx | 5 +- src/components/DetailPanel.tsx | 5 +- src/components/LoadingPill.tsx | 7 +- src/components/ModalOverlay.tsx | 11 +- src/components/SearchBar.tsx | 13 +- src/features/InvestigationPanels.tsx | 48 ++ src/features/channels/ChannelList.tsx | 71 ++- src/features/channels/MessagePanel.tsx | 94 ++-- src/features/channels/types.ts | 3 + src/features/map/PacketPathMap.tsx | 3 +- src/features/map/PacketPathMapModal.tsx | 44 +- src/features/map/packet-flow.ts | 18 +- src/features/map/packet-path.ts | 35 +- src/features/map/useMapBordersData.ts | 9 +- src/features/nodes/NodeDetailOverlay.tsx | 26 - src/features/observers/ObserverComparison.tsx | 97 ++++ .../observers/ObserverDetailPanel.tsx | 39 +- src/features/observers/ObserverPage.tsx | 81 ++++ src/features/observers/ObserverPicker.tsx | 25 + src/features/observers/ObserverSummary.tsx | 59 +++ src/features/observers/ObserverTable.tsx | 29 +- src/features/observers/observer-navigation.ts | 12 + src/features/packets/PacketAnalyzerDrawer.tsx | 55 ++- .../packets/PacketAnalyzerOverlay.tsx | 52 -- src/features/packets/PacketEndpoints.tsx | 42 +- src/features/packets/PacketExpansion.tsx | 4 +- src/features/packets/PacketInvestigation.tsx | 49 ++ src/features/packets/PacketList.tsx | 11 +- src/features/packets/PacketRow.tsx | 4 +- src/features/packets/PacketTableRow.tsx | 11 +- src/features/packets/PacketVirtualList.tsx | 2 +- src/features/packets/PathData.tsx | 5 +- src/features/packets/packet-grid.ts | 2 +- src/features/packets/packet-investigation.ts | 39 ++ src/features/packets/path-summary.ts | 14 +- src/features/routes/RouteDetailPanel.tsx | 3 + src/features/routes/RouteEvidencePanel.tsx | 71 +++ src/features/routes/RouteTable.tsx | 61 ++- src/features/stats/CompareObserversTab.tsx | 8 +- src/features/stats/MeshTab.tsx | 15 +- src/features/stats/ObserverTab.tsx | 455 ++++++++---------- src/features/stats/StatsOverview.tsx | 22 +- src/features/stats/StatsSubHeader.tsx | 12 +- src/features/stats/chartOptions.ts | 28 +- src/features/stats/types.ts | 15 +- src/features/stats/useTelemetry.ts | 18 +- src/hooks/useTick.ts | 18 +- src/i18n/locales/en.json | 195 +++++++- src/i18n/locales/fr.json | 195 +++++++- src/lib/constants.ts | 2 +- src/types/api.ts | 27 ++ .../App.analyzerObservationCarryOver.test.tsx | 9 + tests/App.observerInvestigation.test.tsx | 135 ++++++ tests/App.packetUrlContract.test.tsx | 25 +- tests/App.pathLinkRestore.test.tsx | 7 + tests/App.quickObserver.test.tsx | 37 ++ tests/api/observer-comparison.test.ts | 20 +- tests/api/route-evidence.test.ts | 16 + tests/components/CopyLinkButton.test.tsx | 8 + tests/components/ModalOverlay.test.tsx | 18 + tests/components/SearchBar.compact.test.tsx | 20 + .../channels/MessagePanel.scope.test.tsx | 181 +++++++ tests/features/channels/MessagePanel.test.tsx | 3 +- .../features/map/PacketPathMapModal.test.tsx | 20 +- tests/features/map/packet-flow.test.ts | 10 +- .../features/map/packet-path-evidence.test.ts | 22 + tests/features/map/packet-path.test.ts | 19 +- .../map/useMapBordersData.refresh.test.tsx | 29 ++ .../observers/ObserverComparison.test.tsx | 87 ++++ .../observers/ObserverDetailPanel.test.tsx | 7 +- .../features/observers/ObserverPage.test.tsx | 111 +++++ .../ObserverSummary.compact.test.tsx | 21 + .../observers/observer-navigation.test.ts | 10 + .../packets/PacketAnalyzerDrawer.test.tsx | 58 ++- .../features/packets/PacketEndpoints.test.tsx | 49 +- tests/features/packets/PacketList.test.tsx | 2 +- .../features/packets/PacketTableRow.test.tsx | 12 +- .../packets/packet-investigation.test.ts | 30 ++ tests/features/packets/path-summary.test.ts | 16 - .../routes/RouteEvidencePanel.test.tsx | 99 ++++ tests/features/routes/RouteTable.test.tsx | 18 +- .../stats/CompareObserversTab.test.tsx | 15 +- tests/features/stats/MeshTab.test.tsx | 14 +- tests/features/stats/ObserverTab.test.tsx | 105 +++- tests/features/stats/StatsSubHeader.test.tsx | 21 +- .../stats/analytics-navigation.test.tsx | 13 +- tests/features/stats/useTelemetry.test.ts | 1 + tests/lib/constants.test.ts | 4 +- 93 files changed, 2901 insertions(+), 811 deletions(-) create mode 100644 src/features/InvestigationPanels.tsx delete mode 100644 src/features/nodes/NodeDetailOverlay.tsx create mode 100644 src/features/observers/ObserverComparison.tsx create mode 100644 src/features/observers/ObserverPage.tsx create mode 100644 src/features/observers/ObserverPicker.tsx create mode 100644 src/features/observers/ObserverSummary.tsx create mode 100644 src/features/observers/observer-navigation.ts delete mode 100644 src/features/packets/PacketAnalyzerOverlay.tsx create mode 100644 src/features/packets/PacketInvestigation.tsx create mode 100644 src/features/packets/packet-investigation.ts create mode 100644 src/features/routes/RouteEvidencePanel.tsx create mode 100644 tests/App.observerInvestigation.test.tsx create mode 100644 tests/App.quickObserver.test.tsx create mode 100644 tests/api/route-evidence.test.ts create mode 100644 tests/components/SearchBar.compact.test.tsx create mode 100644 tests/features/channels/MessagePanel.scope.test.tsx create mode 100644 tests/features/map/packet-path-evidence.test.ts create mode 100644 tests/features/map/useMapBordersData.refresh.test.tsx create mode 100644 tests/features/observers/ObserverComparison.test.tsx create mode 100644 tests/features/observers/ObserverPage.test.tsx create mode 100644 tests/features/observers/ObserverSummary.compact.test.tsx create mode 100644 tests/features/observers/observer-navigation.test.ts create mode 100644 tests/features/packets/packet-investigation.test.ts create mode 100644 tests/features/routes/RouteEvidencePanel.test.tsx diff --git a/docs/translations.md b/docs/translations.md index 0e55c96c..b747d283 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -9,10 +9,17 @@ region/theme controls, connection/retry labels, lazy-page loading, shared analytics section/range controls and chart states, and Traffic, RF / Signal, Paths & Hashes, Scopes and Clock Drift (headings, legends, descriptive chart labels, explanations and exact tables). Shared Timestamp labels and their relative tooltips also follow the selected -language. Other feature pages and general dialogs remain follow-up work. Measurement units +language. Packet reception-evidence controls and path-map controls explain retained +reports, intended TRACE routes, unavailable selections and ambiguous/missing map +locations in both languages. Packet bytes, identifiers and routing values stay unchanged. Other feature pages and general dialogs remain follow-up work. Measurement units and existing UTC/date/number formatting, including automatic chart time labels, are unchanged; selecting French does not mean every screen is translated yet. +The channel message panel also translates scope labels, evidence help, retained-history/key +availability states, pagination and packet-inspection actions. Scope names, hashes and message +bodies remain unchanged. The channel directory and its existing search controls are separate +translation follow-ups. + ## Add a language 1. Copy `src/i18n/locales/en.json` to a language-tag filename such as `de.json`. diff --git a/package-lock.json b/package-lock.json index ed18e9ae..45762963 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "1.3.1", + "version": "1.3.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "1.3.1", + "version": "1.3.2", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 284061c9..99d8eb93 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "1.3.1", + "version": "1.3.2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/App.tsx b/src/App.tsx index 28fcb796..8c98e732 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, lazy, Suspense } from "react"; -import { BrowserRouter, useSearchParams } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useLocation, useNavigate, useSearchParams, type Location } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { RegionProvider, useRegion, useRegionSelection } from "./hooks/useRegion"; @@ -18,12 +18,11 @@ import { AppShell } from "./components/AppShell"; import { SplashScreen } from "./components/SplashScreen"; import { PacketList } from "./features/packets/PacketList"; import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer"; -import { PacketAnalyzerOverlay } from "./features/packets/PacketAnalyzerOverlay"; -import { PacketPathMapModal } from "./features/map/PacketPathMapModal"; import { NodeTable } from "./features/nodes/NodeTable"; import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel"; -import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay"; -import { ObserverTable } from "./features/observers/ObserverTable"; +import { InvestigationPanel, type Investigation, type InvestigationTarget } from "./features/InvestigationPanels"; +import { ObserverPage } from "./features/observers/ObserverPage"; +import { observerDestination } from "./features/observers/observer-navigation"; import { RouteTable } from "./features/routes/RouteTable"; import { TraceList } from "./features/traces/TraceList"; import { ChannelList } from "./features/channels/ChannelList"; @@ -89,13 +88,14 @@ function RegionWatcher({ wsManager: mgr }: { wsManager: WsManager }) { // history. function RegionUrlSync() { const { selection } = useRegionSelection(); + const location = useLocation(); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { const next = selectionToParams(selection, searchParams); if (next.toString() === searchParams.toString()) return; - setSearchParams(next, { replace: true }); - }, [selection, searchParams, setSearchParams]); + setSearchParams(next, { replace: true, state: location.state }); + }, [selection, searchParams, setSearchParams, location.state]); return null; } @@ -114,11 +114,11 @@ export function PathLinkRestore({ initialPath, hash, analyzerDetail, onRestore } const handledRef = useRef(false); useEffect(() => { - const detail = analyzerDetail ?? pathLinkDetail; + const detail = analyzerDetail?.packetHash.toLowerCase() === hash?.toLowerCase() ? analyzerDetail : pathLinkDetail; if (!initialPath || !detail || handledRef.current) return; handledRef.current = true; onRestore(detail, initialPath); - }, [initialPath, analyzerDetail, pathLinkDetail, onRestore]); + }, [initialPath, hash, analyzerDetail, pathLinkDetail, onRestore]); return null; } @@ -148,14 +148,20 @@ function TabLoading({ tab }: { tab: string }) { return ; } -function AppInner() { +function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: { + observerVisit?: Location; onObserverDashboard: (id: string) => void; + onReturn: () => void; onExitVisit: () => void; +}) { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); + const location = useLocation(); // The URL is the single source of truth for the active tab — back/forward just work, and an // unknown ?tab value falls back to Packets instead of rendering a blank pane. // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); - const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); + const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer"; + const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -164,61 +170,78 @@ function AppInner() { const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null; const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); - // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts - const [selectedObserverId, setSelectedObserverId] = useState(() => searchParams.get("observer")); - // node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop) - const [overlayNodeId, setOverlayNodeId] = useState(null); - // packet analyzer shown as a modal over the node panel (clicking a node's observation row) - const [overlayPacketHash, setOverlayPacketHash] = useState(null); - // packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map") - const [pathMapDetail, setPathMapDetail] = useState(null); - // Frozen together: the restore must fetch the hash the link asked for, even if the user clicks a - // different row before it resolves. + useEffect(() => { + if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true }); + }, [legacyObserver, searchParams, setSearchParams]); + const [panels, setPanels] = useState([]); + const scene = JSON.stringify([activeTab, searchParams.get("route"), searchParams.get("hash"), searchParams.get("node"), searchParams.get("observer"), searchParams.get("analyze")]); + const [panelScene, setPanelScene] = useState(scene); + // Browser Back to another entity ends its panels; dashboard visits freeze this location instead. + if (panelScene !== scene) { setPanelScene(scene); setPanels([]); } + const dashboardFocus = useRef(null); + const openPanel = useCallback((target: InvestigationTarget) => { + const key = target.kind === "packet" ? `packet:${target.hash.toLowerCase()}:${target.observationId ?? ""}` : target.kind === "path" ? `path:${target.detail.packetHash.toLowerCase()}:${target.selectedKey ?? "all"}` : `${target.kind}:${target.id}`; + setPanels(previous => { + const existing = previous.findIndex(panel => panel.key === key); + return existing < 0 ? [...previous, { key, target }] : previous.slice(0, existing + 1); + }); + }, [setPanels]); + const viewNode = useCallback((id: string) => openPanel({ kind: "node", id }), [openPanel]); + const viewObserver = useCallback((id: string) => openPanel({ kind: "observer", id }), [openPanel]); + const viewPacket = useCallback((hash: string, observationId?: number) => openPanel({ kind: "packet", hash, observationId }), [openPanel]); + const handleViewPath = useCallback((detail: PacketDetail, key?: string) => openPanel({ kind: "path", detail, selectedKey: key }), [openPanel]); const [pathLink] = useState(() => ({ path: searchParams.get("path"), hash: searchParams.get("hash") })); - const [pathMapInitialKey, setPathMapInitialKey] = useState(null); - const { data: analyzerDetail, isLoading: analyzerLoading } = usePacketDetail(analyzerHash); - const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = usePacketDetail(overlayPacketHash); - const handlePathLinkRestore = useCallback((detail: PacketDetail, key: string) => { - setPathMapDetail(detail); - setPathMapInitialKey(key); - }, []); + useEffect(() => { + if (!observerVisit && dashboardFocus.current?.isConnected) dashboardFocus.current.focus(); + }, [observerVisit]); - // "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own - const handleViewPath = useCallback((detail: PacketDetail) => { - setPathMapDetail(detail); - setPathMapInitialKey(null); - }, []); + const closePanel = (index: number) => { + setPanels(previous => previous.slice(0, index)); + if (panels[index]?.target.kind === "path" && searchParams.has("path")) setSearchParams(previous => { + const next = new URLSearchParams(previous); next.delete("path"); return next; + }, { replace: true }); + }; - const handleAnalyze = useCallback((hash: string | null) => { + const selectObservation = useCallback((id: number | null) => { + setSelectedObservationId(id); + setSearchParams(previous => { + const next = new URLSearchParams(previous); + if (id == null) next.delete("observation"); else next.set("observation", String(id)); + return next; + }, { replace: true }); + }, [setSearchParams, setSelectedObservationId]); + + const handleAnalyze = useCallback((hash: string | null, observationId?: number) => { // No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer. setSearchParams((p) => { const n = new URLSearchParams(p); - if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); } + if (hash) { if (n.get("hash") !== hash) n.delete("observation"); n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); if (observationId != null) n.set("observation", String(observationId)); } else n.delete("analyze"); return n; }, { replace: true }); }, [setSearchParams]); const handleTabChange = (tab: string) => { - setOverlayNodeId(null); - setOverlayPacketHash(null); - setPathMapDetail(null); + setPanels([]); + dashboardFocus.current = null; + onExitVisit(); // On mobile a detail panel (and the analyzer) fills the screen, so leaving its tab must close it; // desktop side panels persist across tabs. Cross-nav (onViewObserver) re-sets its selection after this. if (isMobile) { setSelectedObservationId(null); setSelectedNodeId(null); - setSelectedObserverId(null); } setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", tab); + if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); + if (tab !== "Observers") next.delete("observer"); // stats sub-state shouldn't haunt the URL on other tabs - if (tab !== "Analytics") { + if (tab !== "Analytics" && tab !== "Observers") { next.delete("statsTab"); next.delete("observerId"); next.delete("range"); @@ -229,16 +252,15 @@ function AppInner() { const clearSelection = useCallback(() => { setSelectedNodeId(null); - setOverlayNodeId(null); - setOverlayPacketHash(null); - setSelectedObserverId(null); - setPathMapDetail(null); - }, []); + setPanels([]); + dashboardFocus.current = null; + onExitVisit(); + }, [onExitVisit, setSelectedNodeId, setPanels]); // Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet // analyzer's ?analyze cleanup). Selecting a different node/observer doesn't touch the URL — the panel's // Copy Link button rebuilds a fresh link on demand. - const dropSelectionParam = useCallback((key: "node" | "observer") => { + const dropSelectionParam = useCallback((key: "node") => { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete(key); @@ -249,28 +271,12 @@ function AppInner() { const handleCloseNode = useCallback(() => { setSelectedNodeId(null); dropSelectionParam("node"); - }, [dropSelectionParam]); - - const handleSelectObserver = useCallback((id: string | null) => { - setSelectedObserverId(id); - if (id === null) dropSelectionParam("observer"); - }, [dropSelectionParam]); - - // Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected). - const handleViewObserverStats = useCallback( - (id: string) => { - setOverlayNodeId(null); - setOverlayPacketHash(null); - setSearchParams((prev) => { - const next = new URLSearchParams(prev); - next.set("tab", "Analytics"); - next.set("statsTab", "observer"); - next.set("observerId", id); - return next; - }); - }, - [setSearchParams], - ); + }, [dropSelectionParam, setSelectedNodeId]); + + const handleViewObserverStats = useCallback((id: string) => { + dashboardFocus.current = document.activeElement !== document.body ? document.activeElement as HTMLElement | null : null; + onObserverDashboard(id); + }, [onObserverDashboard]); useEffect(() => { // Region slugs can't be expanded yet (region details load async) — connect with the directly @@ -291,29 +297,31 @@ function AppInner() { /> ), Nodes: , - Observers: , - Routes: , + Observers: , + Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses - Traces: , + Traces: { if (hash) viewPacket(hash); }} onViewNode={viewNode} />, Channels: , - Analytics: , + Analytics: , Map: , }; return ( - + } /> - -
+ +
+ {/* Preserve measurable geometry: display:none makes virtualized lists lose their offset. */} +
}> {tabContent[activeTab]} @@ -324,71 +332,54 @@ function AppInner() { detail={analyzerDetail} loading={analyzerLoading} selectedObservationId={selectedObservationId} - onSelectObservation={setSelectedObservationId} + onSelectObservation={selectObservation} onClose={() => handleAnalyze(null)} - onViewNode={setOverlayNodeId} - onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }} + onViewNode={viewNode} + onViewObserver={viewObserver} + onViewPath={(key) => { if (analyzerDetail) handleViewPath(analyzerDetail, key); }} /> )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( { - handleTabChange("Observers"); - setSelectedObserverId(observerId); - }} + onViewObserver={viewObserver} onViewNode={setSelectedNodeId} - onAnalyzePacket={setOverlayPacketHash} - /> - )} - {overlayNodeId && ( - setOverlayNodeId(null)} - onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); - }} - onViewNode={setOverlayNodeId} - /> - )} - {overlayPacketHash && ( - setOverlayPacketHash(null)} - onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); - }} - onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} - inactive={!!pathMapDetail} - /> - )} - {pathMapDetail && ( - { - setPathMapDetail(null); - setSearchParams((prev) => { const n = new URLSearchParams(prev); n.delete("path"); return n; }, { replace: true }); - }} + onAnalyzePacket={viewPacket} /> )} + {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} />)} +
+ {observerVisit && } />}
); } +// A dashboard visit keeps one originating screen under its original location context. A reload has +// no in-memory origin, so copied/canonical dashboard URLs remain standalone destinations. +function AppNavigation() { + const location = useLocation(); + const navigate = useNavigate(); + const [origin, setOrigin] = useState(null); + const visiting = origin != null && location.state?.beaconObserverReturnKey === origin.key && new URLSearchParams(location.search).get("tab") === "Observers"; + if (origin && !visiting && location.key !== origin.key) setOrigin(null); + const exitVisit = useCallback(() => setOrigin(null), []); + const openDashboard = (id: string) => { + setOrigin(location); + navigate({ search: "?" + observerDestination(new URLSearchParams(location.search), id).toString() }, { state: { beaconObserverReturnKey: location.key } }); + }; + return navigate(-1)} onExitVisit={exitVisit} />} />; +} + export function App() { return ( - + diff --git a/src/api/client.ts b/src/api/client.ts index da77179e..4990248c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,6 +1,6 @@ import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants"; import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit"; -import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; +import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, RouteEvidence, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api"; import type { ChannelPage, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; @@ -142,14 +142,16 @@ export function getChannels(params?: { iatas?: string[]; limit?: number; cursor? // a CursorPage so MessagePanel can load older history on demand via useInfiniteQuery. export async function getChannelMessagesPage( channelId: number, - params?: { iatas?: string[]; cursor?: number; limit?: number }, + params?: { iatas?: string[]; cursor?: number; limit?: number; scope?: string }, ): Promise> { const limit = params?.limit ?? DEFAULT_PAGE_SIZE; - const page = await request<{ items: ChannelMessage[] }>(`/channels/${channelId}/messages`, { + const page = await request<{ items: ChannelMessage[]; nextCursor?: number | null; hasMore?: boolean }>(`/channels/${channelId}/messages`, { iatas: iatasParam(params?.iatas), cursor: params?.cursor, limit, + scope: params?.scope || undefined, }); + if (typeof page.hasMore === "boolean") return { items: page.items, nextCursor: page.nextCursor ?? null, hasMore: page.hasMore }; return toCursorPage(page.items, limit, (m) => m.id); } @@ -187,6 +189,10 @@ export async function getKnownRoutesPage( return toCursorPage(items, limit, (r) => r.lastSeen); } +export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number }, signal?: AbortSignal): Promise { + return request(`/routes/${encodeURIComponent(iata)}/${encodeURIComponent(pathKey)}/observations`, params, signal); +} + // Search known routes for a path between two node hash prefixes within a single IATA. All three params // are required by the server. export function searchKnownRoutes(iata: string, from: string, to: string): Promise { @@ -225,8 +231,15 @@ export function getTraceDetail(tag: string): Promise { return request(`/traces/${tag}`); } -export function getObserver(observerId: string): Promise { - return request(`/observers/${observerId}`); +export async function getObserver(observerId: string): Promise { + const observer = await request(`/observers/${observerId}`); + // Older servers marshal the stored JSON byte slice as base64 instead of an object. + let metadata: unknown = observer.statusMetadata; + if (typeof metadata === "string") { + try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } + catch { metadata = undefined; } + } + return { ...observer, statusMetadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata as Record : undefined }; } export function getObserverAdverts( @@ -368,8 +381,8 @@ export function getObserverTelemetry( return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); } -export function getObserverActivity(observerId: string, range: string, interval: string): Promise { - return request(`/observers/${observerId}/activity`, { range, interval }); +export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise { + return request(`/observers/${observerId}/activity`, { range, interval, until }); } // Lets a caller hide a feature the server doesn't have rather than show it as failed. diff --git a/src/components/CopyLinkButton.tsx b/src/components/CopyLinkButton.tsx index 71cb4dc0..ea83a4b6 100644 --- a/src/components/CopyLinkButton.tsx +++ b/src/components/CopyLinkButton.tsx @@ -10,16 +10,19 @@ export function CopyLinkButton({ label = "Copy Link", copiedLabel = "Copied", ariaLabel, + preserveParams, }: { params: Record | (() => Record); label?: string; copiedLabel?: string; ariaLabel?: string; + preserveParams?: readonly string[]; }) { const [copied, setCopied] = useState(false); const handleCopy = useCallback(() => { const url = new URL(window.location.href); + if (preserveParams) for (const key of [...url.searchParams.keys()]) if (!preserveParams.includes(key)) url.searchParams.delete(key); const resolved = typeof params === "function" ? params() : params; for (const [key, value] of Object.entries(resolved)) { if (value === null) url.searchParams.delete(key); @@ -28,7 +31,7 @@ export function CopyLinkButton({ navigator.clipboard.writeText(url.toString()); setCopied(true); setTimeout(() => setCopied(false), 1500); - }, [params]); + }, [params, preserveParams]); return (
diff --git a/src/components/LoadingPill.tsx b/src/components/LoadingPill.tsx index d8ca1f0d..258d5b99 100644 --- a/src/components/LoadingPill.tsx +++ b/src/components/LoadingPill.tsx @@ -3,12 +3,13 @@ interface LoadingPillProps { error?: boolean; count: number; // rows loaded so far noun: string; // plural entity name, e.g. "nodes" / "observers" + label?: string; // optional complete localized phrase position?: string; // corner placement (the parent must be `relative`) } // Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while // pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle. -export function LoadingPill({ loading, error, count, noun, position = "bottom-3 left-3" }: LoadingPillProps) { +export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) { if (!loading && !error) return null; const tone = loading ? "text-text-muted" : "text-danger"; const dot = loading ? "bg-primary animate-pulse" : "bg-danger"; @@ -18,11 +19,11 @@ export function LoadingPill({ loading, error, count, noun, position = "bottom-3 className={`absolute ${position} z-10 flex items-center gap-2 px-2.5 py-1 bg-bg-surface border border-border-subtle rounded-md font-mono text-[11px] ${tone} shadow-lg`} > - {loading + {label ?? (loading ? `Loading ${noun}… (${count})` : count > 0 ? `Some ${noun} failed to load (${count} shown)` - : `Failed to load ${noun}`} + : `Failed to load ${noun}`)}
); } diff --git a/src/components/ModalOverlay.tsx b/src/components/ModalOverlay.tsx index 3babbf5c..94882eef 100644 --- a/src/components/ModalOverlay.tsx +++ b/src/components/ModalOverlay.tsx @@ -3,7 +3,7 @@ import { useFocusTrap } from "../hooks/useFocusTrap"; // Right-anchored modal: dims the surface, focuses and traps keyboard focus within the panel, and // closes on a backdrop click — only when the press started there, so releasing a text selection over -// the backdrop doesn't close. Escape is left to the caller (some callers gate it on nested state). +// the backdrop doesn't close. Escape closes only this active layer and stops before parent handlers. // Pass `inactive` when another overlay is stacked on top so this one steps out of the modal/a11y // path — it stops being an active modal and is hidden from assistive tech, while staying mounted so // focus can return into it when the overlay above closes. @@ -26,8 +26,15 @@ export function ModalOverlay({ label, onClose, inactive = false, children }: { aria-label={label} aria-hidden={inactive || undefined} tabIndex={-1} + onKeyDown={(e) => { + if (e.key === "Escape" && !inactive) { + e.preventDefault(); + e.stopPropagation(); + onClose(); + } + }} onMouseDown={(e) => { pressedBackdrop.current = e.target === e.currentTarget; }} - onClick={() => { if (pressedBackdrop.current) onClose(); }} + onClick={() => { if (!inactive && pressedBackdrop.current) onClose(); }} >
e.stopPropagation()}> {children} diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index ceaeef72..9190fdd7 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -13,11 +13,13 @@ interface SearchBarProps { fields: SearchFieldOption[]; field: string; onFieldChange: (field: string) => void; + hideField?: boolean; + inputLabel?: string; } // debounced search input with field dropdown -export function SearchBar({ value, onChange, fields, field, onFieldChange }: SearchBarProps) { +export function SearchBar({ value, onChange, fields, field, onFieldChange, hideField = false, inputLabel }: SearchBarProps) { const [localValue, setLocalValue] = useState(value); const [prevValue, setPrevValue] = useState(value); const debounceRef = useRef | null>(null); @@ -56,7 +58,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea return (
- ( @@ -92,7 +94,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange }: Sea {f.label} ))} - + }
handleChange(e.target.value)} - placeholder={`Search by ${(currentField?.label ?? "").toLowerCase()}...`} - className="w-full text-[11px] font-mono bg-bg-surface border border-border rounded-r-sm pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors" + placeholder={inputLabel ?? `Search by ${(currentField?.label ?? "").toLowerCase()}...`} + className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "min-h-11 rounded-sm text-base md:text-sm" : "rounded-r-sm text-[11px]"}`} /> {localValue && (
- +
+ {msg.senderName} {reach > 0 && ×{reach}} + {msg.scope ? {scopeLabel} : {scopeLabel}} + {onAnalyze && }
{msg.content}
@@ -51,20 +61,30 @@ interface MessagePanelProps { onAnalyze?: (packetHash: string) => void; // mobile-only: renders a back button, since here the panel replaces the channel list onBack?: () => void; + scope?: string; + onScopeChange?: (scope: string) => void; } -export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack }: MessagePanelProps) { - const { data, isLoading, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ - queryKey: ["channel-messages", channel?.id, regionKey], - queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50 }), - getNextPageParam: (last) => last.nextCursor ?? undefined, +export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze, onBack, scope = "", onScopeChange }: MessagePanelProps) { + const { t } = useTranslation(); + const scopeNames = useScopes(); + const { data, isLoading, isError, isFetching, refetch, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ["channel-messages", channel?.id, regionKey, scope], + queryFn: ({ pageParam }) => getChannelMessagesPage(channel!.id, { iatas, cursor: pageParam, limit: 50, scope }), + getNextPageParam: (last) => last.hasMore ? last.nextCursor ?? undefined : undefined, initialPageParam: undefined as number | undefined, enabled: channel !== null, staleTime: 30_000, }); // flatten order is irrelevant — the ascending sort below restores chat order from newest-first pages - const messages = useMemo(() => data?.pages.flatMap((p) => p.items), [data]); + const messages = useMemo(() => { + const unique = new Map(); + for (const page of data?.pages ?? []) { + for (const message of page.items) if (!unique.has(message.packetHash)) unique.set(message.packetHash, message); + } + return [...unique.values()]; + }, [data]); const sorted = useMemo( () => [...(messages ?? [])].sort((a, b) => a.sentAt - b.sentAt), @@ -74,7 +94,8 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const bottomRef = useRef(null); const [userScrolled, setUserScrolled] = useState(false); const scrollContainerRef = useRef(null); - const scrollAnchor = useRef<{ channelId?: number; count: number }>({ count: 0 }); + const viewKey = `${channel?.id ?? ""}:${scope}`; + const scrollAnchor = useRef<{ view?: string; count: number }>({ count: 0 }); // pre-prepend scroll metrics, captured while a load-older fetch is in flight const prepend = useRef<{ pending: boolean; prevHeight: number; prevTop: number }>({ pending: false, @@ -83,9 +104,9 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze }); // reset scroll tracking on channel switch — adjust state during render, not in an effect - const [prevChannelId, setPrevChannelId] = useState(channel?.id); - if (prevChannelId !== channel?.id) { - setPrevChannelId(channel?.id); + const [prevView, setPrevView] = useState(viewKey); + if (prevView !== viewKey) { + setPrevView(viewKey); setUserScrolled(false); } @@ -93,10 +114,10 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const el = scrollContainerRef.current; const anchor = scrollAnchor.current; - if (anchor.channelId !== channel?.id) { + if (anchor.view !== viewKey) { // first batch for this channel — jump to the bottom once it lands if (isLoading) return; - anchor.channelId = channel?.id; + anchor.view = viewKey; anchor.count = sorted.length; prepend.current = { pending: false, prevHeight: 0, prevTop: 0 }; if (el) el.scrollTop = el.scrollHeight; @@ -115,7 +136,14 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze bottomRef.current?.scrollIntoView({ behavior: "smooth" }); // live message arrived; follow it down } anchor.count = sorted.length; - }, [sorted.length, channel?.id, isLoading, userScrolled]); + }, [sorted.length, viewKey, isLoading, userScrolled]); + + const loadOlder = useCallback(() => { + if (!hasNextPage || isFetching) return; + const el = scrollContainerRef.current; + prepend.current = { pending: true, prevHeight: el?.scrollHeight ?? 0, prevTop: el?.scrollTop ?? 0 }; + void fetchNextPage(); + }, [hasNextPage, isFetching, fetchNextPage]); const handleScroll = useCallback(() => { const el = scrollContainerRef.current; @@ -123,16 +151,13 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40; setUserScrolled(!atBottom); // near the top: pull the next older page, capturing metrics so the prepend can hold position - if (el.scrollTop < 40 && hasNextPage && !isFetchingNextPage) { - prepend.current = { pending: true, prevHeight: el.scrollHeight, prevTop: el.scrollTop }; - fetchNextPage(); - } - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + if (el.scrollTop < 40) loadOlder(); + }, [loadOlder]); if (!channel) { return (
- Select a channel + {t("channelMessages.select")}
); } @@ -145,7 +170,7 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
{channel.keyKnown ? ( - key known + {t("channelMessages.keyKnown")} ) : ( - no key + {t("channelMessages.noKey")} )} - {channel.isHashtag && hashtag} + {channel.isHashtag && {t("channelMessages.hashtag")}}
{!channel.keyKnown && (
- Key not known, messages may not be decrypted + {t("channelMessages.keyMissing")}
)} +
+ {onScopeChange && ({ value: name, label: name }))} align="left" />} +
+ {isError &&
{t("channelMessages.error")}
} +
{isLoading ? (
- Loading... + {t("channelMessages.loading")}
) : messages && messages.length > 0 ? (
+ {hasNextPage && } {sorted.map((msg) => ( ))} @@ -192,13 +224,13 @@ export function MessagePanel({ channel, heardCounts, iatas, regionKey, onAnalyze
) : (
- No messages + {t(!channel.keyKnown ? "channelMessages.emptyNoKey" : scope ? "channelMessages.emptyScope" : "channelMessages.empty", { scope })}
)}
{/* floats over the panel (not the scroll area) so older-page fetches don't shift it */} - +
); } diff --git a/src/features/channels/types.ts b/src/features/channels/types.ts index 927ba652..cfc1a4bf 100644 --- a/src/features/channels/types.ts +++ b/src/features/channels/types.ts @@ -33,4 +33,7 @@ export interface ChannelMessage { content: string; sentAt: number; // epoch ms, from the sender's embedded timestamp observationCount?: number; + channelId?: number; // supplied by live events; channel history already has an ID-specific endpoint + scope?: string | null; // absent on older servers, null when no name was recorded + scopeStatus?: "matched" | "unscoped" | "unknown" | "unavailable"; } diff --git a/src/features/map/PacketPathMap.tsx b/src/features/map/PacketPathMap.tsx index b421868d..2c02b685 100644 --- a/src/features/map/PacketPathMap.tsx +++ b/src/features/map/PacketPathMap.tsx @@ -1,6 +1,7 @@ // src/features/map/PacketPathMap.tsx import { useEffect, useRef, useState } from "react"; import { Map as MapLibreMap, NavigationControl, AttributionControl, Popup, LngLatBounds } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; import "./maplibre-worker"; import type { GeoJSONSource, @@ -111,7 +112,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: { if (!map.getLayer(NODE_LABEL_LAYER)) { map.addLayer({ id: NODE_LABEL_LAYER, type: "symbol", source: NODE_SOURCE, - layout: { "text-field": ["get", "label"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, + layout: { "text-field": ["get", "label"], "text-font": ["Noto Sans Regular"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true }, paint: { "text-color": paletteVar("--palette-text-bright", "#e5e7eb"), "text-halo-color": paletteVar("--palette-bg-base", "#0a0a0a"), diff --git a/src/features/map/PacketPathMapModal.tsx b/src/features/map/PacketPathMapModal.tsx index e9c5d4b1..a00dec86 100644 --- a/src/features/map/PacketPathMapModal.tsx +++ b/src/features/map/PacketPathMapModal.tsx @@ -1,13 +1,15 @@ -import { useEffect, useMemo, useState } from "react"; +import { lazy, Suspense, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import type { PacketDetail } from "../../types/api"; import { ModalOverlay } from "../../components/ModalOverlay"; import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import { formatPropagation } from "../../lib/formatters"; import { buildPacketPaths } from "./packet-path"; -import { PacketPathMap } from "./PacketPathMap"; import { DEFAULT_STYLE_ID, MAP_STYLE_STORAGE_KEY } from "./types"; +const PacketPathMap = lazy(() => import("./PacketPathMap").then(module => ({ default: module.PacketPathMap }))); + // Closable mini-map of a packet's resolved path(s). "All paths" overlays every observation's route; // clicking an observer isolates its path. Lives over the analyzer (no tab switch), so closing it // returns the user exactly where they were. @@ -29,54 +31,54 @@ function Row({ active, color, label, meta, onClick }: { ); } -export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: { +export function PacketPathMapModal({ detail, onClose, initialSelectedKey, inactive = false }: { detail: PacketDetail; onClose: () => void; initialSelectedKey?: string | null; + inactive?: boolean; }) { + const { t } = useTranslation(); const paths = useMemo(() => buildPacketPaths(detail), [detail]); const [selectedKey, setSelectedKey] = useState( - // deep-link value that matches a known path isolates it; anything else (incl. "all") shows All - () => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null), + () => initialSelectedKey && initialSelectedKey !== "all" ? initialSelectedKey : null, ); + const unavailable = selectedKey != null && !paths.some(p => p.key === selectedKey); const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []); - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - return ( - +
- Packet Path + {t("investigation.mapTitle")}
({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null })} - ariaLabel="Copy path link" + preserveParams={["regions", "iata", "region"]} + params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all" })} + label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPath")} /> - +
+

{t("investigation.mapNote")}

+ {unavailable &&

{t("investigation.unavailableMap")}

} + {paths.length === 0 && !unavailable &&

{t("investigation.unmappable")}

}
- + {t("common.loading")}

}> + +
- setSelectedKey(null)} /> + setSelectedKey(null)} />
{paths.map((p) => ( setSelectedKey(p.key)} /> diff --git a/src/features/map/packet-flow.ts b/src/features/map/packet-flow.ts index a4384dfa..6aa47892 100644 --- a/src/features/map/packet-flow.ts +++ b/src/features/map/packet-flow.ts @@ -8,8 +8,8 @@ import { hasMapLocation } from "./location"; // The full chain for one observation: source → relay hops → destination. Both maps plot one marker // per hop, so an ambiguous endpoint (a 1-byte prefix matching several candidate nodes) would force // us to guess which node actually sent or received the packet — only plot endpoints the backend -// resolved unambiguously. Relay hops carry no such gate; they fall back to their first located -// candidate. WS types the endpoints nullable where REST leaves them optional, hence both here. +// resolved unambiguously. Rendering also checks relay confidence and location. WS types the +// endpoints nullable where REST leaves them optional, hence both here. export function packetChain( source: ResolvedHop | null | undefined, path: ResolvedHop[], @@ -19,14 +19,20 @@ export function packetChain( return [confident(source), ...path, confident(destination)].filter((hop): hop is ResolvedHop => hop != null); } -// The located nodes on a packet's resolved path — first candidate per hop, deduped by id. The dot -// rides these coords and flashes each node as it crosses. +export function locatedHopNode(hop: ResolvedHop) { + if (hop.confidence !== "high" || hop.nodes.length !== 1) return undefined; + const node = hop.nodes[0]!; + return hasMapLocation({ lat: node.latitude, lng: node.longitude }) ? node : undefined; +} + +// Animate only a completely located, unambiguous chain; skipping a hop would invent a link. export function resolvedPathNodes(resolvedPath: ResolvedHop[]): { id: string; lng: number; lat: number }[] { const seen = new Set(); const out: { id: string; lng: number; lat: number }[] = []; for (const hop of resolvedPath) { - const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude })); - if (node && !seen.has(node.id)) { + const node = locatedHopNode(hop); + if (!node) return []; + if (!seen.has(node.id)) { seen.add(node.id); out.push({ id: node.id, lng: node.longitude!, lat: node.latitude! }); } diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 2b397759..8e2e0e8e 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -1,14 +1,14 @@ import type { Feature, FeatureCollection, LineString, Point } from "geojson"; import type { PacketDetail, Observation, ResolvedHop } from "../../types/api"; import { PayloadType } from "../../types/enums"; -import { packetChain } from "./packet-flow"; -import { hasMapLocation } from "./location"; +import { packetChain, locatedHopNode } from "./packet-flow"; export interface PathPoint { id: string; name?: string; lng: number; lat: number; + breakBefore?: boolean; } export interface PacketPath { @@ -32,17 +32,19 @@ export const PATH_COLORS: string[] = [ "#a78bfa", // violet ]; -// The located nodes on a resolved path — first candidate per hop that has coords, deduped by id, in -// order. Modelled on resolvedPathNodes() in packet-flow.ts, but keeps each node's name for labels. +// Keep known locations, but break lines at ambiguous/unlocated hops instead of inventing a link. function pathPoints(hops: ResolvedHop[]): PathPoint[] { const seen = new Set(); const out: PathPoint[] = []; + let gap = false; for (const hop of hops) { - const node = hop.nodes.find((n) => hasMapLocation({ lat: n.latitude, lng: n.longitude })); - if (node && !seen.has(node.id)) { + const node = locatedHopNode(hop); + if (!node) { gap = true; continue; } + if (!seen.has(node.id)) { seen.add(node.id); - out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude! }); - } + out.push({ id: node.id, name: node.name, lng: node.longitude!, lat: node.latitude!, ...(gap && out.length ? { breakBefore: true } : {}) }); + gap = false; + } else gap = out.at(-1)?.id !== node.id; } return out; } @@ -66,7 +68,7 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { // lines would just duplicate the single "Trace route" below — draw only that one for traces. if (!isTrace) { for (const obs of detail.observations) { - // full chain: source → relay hops → destination; missing/unlocated hops drop out in pathPoints. + // Full chain: source → relay hops → destination, with gaps retained by pathPoints. const chain = packetChain(obs.resolvedSource, obs.resolvedPath, obs.resolvedDestination); add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); } @@ -105,11 +107,16 @@ export function packetPathsToFeatures( const bounds: [number, number][] = []; for (const path of shown) { - lines.push({ - type: "Feature", - properties: { key: path.key, color: path.color }, - geometry: { type: "LineString", coordinates: path.points.map((p) => [p.lng, p.lat]) }, - }); + let segment: [number, number][] = []; + const finishSegment = () => { + if (segment.length > 1) lines.push({ type: "Feature", properties: { key: path.key, color: path.color }, geometry: { type: "LineString", coordinates: segment } }); + segment = []; + }; + for (const point of path.points) { + if (point.breakBefore) finishSegment(); + segment.push([point.lng, point.lat]); + } + finishSegment(); path.points.forEach((pt, i) => { const endpoint = i === 0 ? "start" : i === path.points.length - 1 ? "end" : "mid"; points.push({ diff --git a/src/features/map/useMapBordersData.ts b/src/features/map/useMapBordersData.ts index f89f326b..34132f39 100644 --- a/src/features/map/useMapBordersData.ts +++ b/src/features/map/useMapBordersData.ts @@ -18,20 +18,19 @@ export function mergeBorders(entries: { iata: string; border: IataBorder | null } // Fetch the border for each active IATA (only while `enabled`), then merge into one collection. -// Borders are static, so each is cached indefinitely and most IATAs simply have none (204 -> null). +// Cache geometry for an hour; missing borders can appear after an operator imports a snapshot. export function useMapBordersData(iataCodes: string[], enabled: boolean): BorderFeatureCollection { const results = useQueries({ queries: iataCodes.map((iata) => ({ queryKey: ["iata-border", iata], queryFn: () => getIataBorder(iata), enabled, - staleTime: Infinity, + staleTime: (query: { state: { data: unknown } }) => query.state.data ? 3_600_000 : 60_000, })), }); - // useQueries returns a fresh array each render; a border is immutable once fetched, so a signature - // of which IATAs have resolved one is enough to keep the collection reference stable between renders. - const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}`).join("|"); + // Keep the collection stable between renders, but replace geometry after a successful refresh. + const sig = iataCodes.map((iata, i) => `${iata}:${results[i]?.data ? 1 : 0}:${results[i]?.dataUpdatedAt ?? 0}`).join("|"); return useMemo( () => mergeBorders(iataCodes.map((iata, i) => ({ iata, border: results[i]?.data ?? null }))), // eslint-disable-next-line react-hooks/exhaustive-deps -- sig captures iataCodes + which borders loaded diff --git a/src/features/nodes/NodeDetailOverlay.tsx b/src/features/nodes/NodeDetailOverlay.tsx deleted file mode 100644 index 514c949f..00000000 --- a/src/features/nodes/NodeDetailOverlay.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect } from "react"; -import { NodeDetailPanel } from "./NodeDetailPanel"; -import { ModalOverlay } from "../../components/ModalOverlay"; - -// Node detail shown as a modal over the packet analyzer: the panel sits where the packet drawer is -// (right side) and the rest dims, so a user can peek at a path hop's node and close back to the packet. -export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode }: { - nodeId: string; - onClose: () => void; - onViewObserver: (observerId: string) => void; - onViewNode?: (nodeId: string) => void; -}) { - useEffect(() => { - function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose]); - - return ( - - - - ); -} diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx new file mode 100644 index 00000000..7d27770c --- /dev/null +++ b/src/features/observers/ObserverComparison.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserverComparison } from "../../api/client"; +import { formatBattery, formatUptime } from "../../lib/formatters"; +import { ObserverPicker } from "./ObserverPicker"; +import { Card, ChartCard } from "../stats/cards"; +import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry"; +import { useChartColors } from "../stats/chartTheme"; +import { heardOption } from "../stats/chartOptions"; +import { fillActivity, intervalToMs } from "../stats/transforms"; +import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types"; +import type { Observer } from "./types"; + +const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +function statusNoise(observer: Observer) { + const stats = observer.statusMetadata?.stats; + return stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? `${stats.noise_floor} dBm` : "—"; +} + +export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: { + observerA: Observer; activityA?: ObserverActivity; range: StatsRange; observerBId: string; until: number | null; + onSelect: (id: string) => void; onRefresh: () => number; +}) { + const { t, i18n } = useTranslation(); const colors = useChartColors(); + const valid = until != null && uuid.test(observerA.id) && uuid.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); + const b = useObserver(valid ? observerBId : null); + const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined); + const dataB = activityB.isError ? undefined : activityB.data; + const intervalMs = intervalToMs(activityParamsFor(range).interval)!; + const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs; + const start = end - RANGE_MS[range]; + const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs); + const overlap = useQuery({ + queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end], + queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal), + enabled: valid && aligned && !!b.data && !b.isError, + staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false, + }); + const option = useMemo(() => { + const window = { start, end }; + const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window) : []; + const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window) : []; + return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 }, + legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } }, + series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({ + name: name as string, type: "line" as const, symbol: "none", connectNulls: false, + data: (points as typeof aPoints).map(p => [p.t, p.observations]), + lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string }, + })), + }; + }, [activityA, dataB, aligned, intervalMs, start, end, colors]); + const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); + const groups = overlap.data && !overlap.isError ? [ + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary }, + ] : []; + const refresh = () => { + const nextUntil = onRefresh(); + if (valid) { + void b.refetch(); + if (nextUntil === until) { + void activityB.refetch(); + if (aligned) void overlap.refetch(); + } + } + }; + const rows = aligned && b.data ? [ + [t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)], + [t("observerPage.lastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`], + [t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)], + [t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)], + [t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)], + [t("observerPage.noise"), statusNoise(observerA), statusNoise(b.data)], + ] : []; + return
+

{t("observerCompare.title")}

+
+

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

+ + {until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> +

{t("observerPage.window")}: {date(start)} – {date(end)}. {t("observerCompare.currentNote")}

+
{rows.map(([label, a, bValue]) => )}
{t("observerCompare.metric")}AB
{label}{a}{bValue}
+ + +

{t("observerCompare.retention")}

+ {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <> +

{t("observerCompare.total", { count: overlap.data.totalPackets })}

+ {overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} +
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+

{t("observerCompare.definition")}

+ } + + } +
; +} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 1dd9c436..535e2be6 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import type { Observer, AdvertObservation } from "./types"; import { useQuery } from "@tanstack/react-query"; import { getObserver, getObserverAdverts } from "../../api/client"; @@ -15,33 +16,35 @@ import { ScopeTag } from "../../components/ScopeTag"; function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick?: () => void }) { const level = snrLevel(advert.snr); + const Tag = onClick ? "button" : "div"; return ( -
-
+ {advert.nodeName ?? (advert.nodePublicKey ? formatHex(advert.nodePublicKey) : "unknown")} {advert.iata} -
-
-
+ + + SNR {formatSnr(advert.snr)} -
-
+ + RSSI {advert.rssi ?? "—"} -
-
+ + Hops {advert.hopCount ?? "—"} -
-
-
+ + + ); } @@ -101,11 +104,12 @@ function RadioSection({ observer, noiseFloor }: { observer: Observer; noiseFloor interface ObserverDetailPanelProps { observerId: string; onClose: () => void; - onAnalyzePacket?: (hash: string) => void; + onAnalyzePacket?: (hash: string, observationId: number) => void; onViewStats?: (observerId: string) => void; } export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onViewStats }: ObserverDetailPanelProps) { + const { t } = useTranslation(); const { data: observer, isLoading } = useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId), @@ -126,6 +130,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi } isLoading={isLoading} notFound={!observer} @@ -155,7 +160,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
- +
{observer.observerType && {observer.observerType}} @@ -173,7 +178,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi - Statistics + {t("observerPage.open")} )} @@ -246,7 +251,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi onAnalyzePacket(a.packetHash) : undefined} + onClick={onAnalyzePacket ? () => onAnalyzePacket(a.packetHash, a.id) : undefined} /> ))}
diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx new file mode 100644 index 00000000..9b85cd74 --- /dev/null +++ b/src/features/observers/ObserverPage.tsx @@ -0,0 +1,81 @@ +import { lazy, Suspense, useState, useEffect, useRef } from "react"; +import { useLocation, useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { CopyButton } from "../../components/CopyButton"; +import { useTick } from "../../hooks/useTick"; +import { ObserverTable } from "./ObserverTable"; +import { observerDestination, observerRange } from "./observer-navigation"; +import type { WsManager } from "../../api/ws-manager"; +const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); + +export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: WsManager; onReturn?: () => void; returnLabel?: string }) { + const [params, setParams] = useSearchParams(); + const location = useLocation(); + const returnButton = useRef(null); + useEffect(() => { returnButton.current?.focus(); }, []); + // Dashboard adjustments belong to this visit; Back returns to the original investigation. + const visitOptions = onReturn ? { replace: true, state: location.state } : undefined; + const { t } = useTranslation(); + const now = useTick(60_000); + const id = params.get("observer"); + const [actionTime, setActionTime] = useState(() => Date.now()); + const hourAt = (time: number) => Math.floor(time / 3_600_000) * 3_600_000; + const comparing = params.has("compareWith"); + const anchor = params.has("compareUntil") ? Number(params.get("compareUntil")) : hourAt(actionTime); + const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= Math.max(now, actionTime); + const until = validAnchor ? anchor : null; + const range = observerRange(params.get("range")); + const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); + if (id) share.searchParams.set("range", range); + if (comparing && until != null) share.searchParams.set("compareUntil", String(until)); + const select = (observer: string | null) => setParams(observerDestination(params, observer, range), visitOptions); + const compare = (observer: string) => { + // eslint-disable-next-line react-hooks/purity -- Capture time when the user invokes this event callback. + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareWith", observer); + next.set("compareUntil", String(comparing ? until ?? hourAt(clickedAt) : hourAt(clickedAt))); + return next; + }, visitOptions); + }; + const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; }, visitOptions); + const refreshCompare = () => { + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareUntil", String(hourAt(clickedAt))); + return next; + }, { replace: true, state: location.state }); + return hourAt(clickedAt); + }; + return
+ {(id || onReturn) &&
+ {onReturn && } + {id && <> + + + + + } +
} +
+ + {id &&
+
+ {t("common.loading")}

}> + +
+
+
} +
+
; +} diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx new file mode 100644 index 00000000..39b5fbff --- /dev/null +++ b/src/features/observers/ObserverPicker.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; + +export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { + const { t } = useTranslation(); + const { iatas, regionKey } = useRegion(); + const [text, setText] = useState(""); + const [query, setQuery] = useState(""); + useEffect(() => { const timer = setTimeout(() => setQuery(text.trim()), 250); return () => clearTimeout(timer); }, [text]); + const options = useQuery({ queryKey: ["observer-picker", regionKey, query], queryFn: () => getObserversPage(iatas, { name: query || undefined, limit: 50 }), staleTime: 30_000 }); + const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); + return
+
+ setText(e.target.value)} className="min-h-11 min-w-0 flex-1 rounded border border-border bg-bg-base px-3 text-text-normal" /> + +
+ {options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

} +
; +} diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx new file mode 100644 index 00000000..d5513dca --- /dev/null +++ b/src/features/observers/ObserverSummary.tsx @@ -0,0 +1,59 @@ +import { useTranslation } from "react-i18next"; +import { Timestamp } from "../../components/Timestamp"; +import { CopyButton } from "../../components/CopyButton"; +import { formatBattery, formatRadioParts, formatUptime } from "../../lib/formatters"; +import { useTick } from "../../hooks/useTick"; +import type { Observer } from "./types"; +import type { ObserverActivity, TelemetryPoint } from "../stats/types"; + +export function ObserverSummary({ observer, activity, points, pending = false }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean }) { + const { t, i18n } = useTranslation(); const now = useTick(); + const summary = activity?.summary; + const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; + const stats = observer.statusMetadata?.stats; + const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; + const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; + const cards = [ + ["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"], + ["lastHour", summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—"], + ["lastPacket", summary?.latestRecordedAt != null ? : "—"], + ["battery", observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"], + ["uptime", observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"], + ["noise", noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—"], + ] as const; + return <> +
+

{observer.displayName ?? observer.id.slice(0, 8)}

+
+ + + + {observer.iata} +
+
+
+
    + {cards.map(([key, value]) =>
  • +
    {t(`observerPage.${key}`)}
    +
    {value}
    + {key === "lastHour" && summary &&
    {new Date(summary.lastCompleteHourStart).toISOString().slice(11, 16)}–{new Date(summary.lastCompleteHourEnd).toISOString().slice(11, 16)} UTC
    } + {key === "noise" && noise != null &&
    {t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")}
    } +
  • )} +
+ {!summary && !pending &&

{t("observerPage.summaryMissing")}

} + ; +} + +export function ObserverDeviceDetails({ observer }: { observer: Observer }) { + const { t } = useTranslation(); + const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); + return
+ {t("observerPage.details")} +
+ {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) =>
{t(`observerPage.${key}`)}
{value ?? "—"}
)} +
{t("observerPage.publicKey")}
{observer.publicKey}
+
{t("observerPage.firstSeen")}
+
+
    {observer.brokers.map(b =>
  • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
  • )}
+
; +} diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 21eab8e9..b298424c 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getObserversPage, getBrokers } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; @@ -9,6 +10,7 @@ import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; import { formatHex, formatRadio } from "../../lib/formatters"; import { Badge } from "../../components/Badge"; import { DataTable, type Column } from "../../components/DataTable"; +import { SearchBar } from "../../components/SearchBar"; import { LoadingPill } from "../../components/LoadingPill"; import { ObserverFilterBar } from "./ObserverFilterBar"; import { ObserverDetailPanel } from "./ObserverDetailPanel"; @@ -23,6 +25,7 @@ import type { WsObserverStatus } from "../../types/ws"; const observerId = (o: ObserverSummary) => o.id; // stable id accessor for the paged hook's dedup interface ObserverTableProps { + compact?: boolean; wsManager: WsManager; selectedObserverId: string | null; onSelectObserver: (id: string | null) => void; @@ -71,6 +74,12 @@ const COLUMNS: Column[] = [ }, ]; +const COMPACT_COLUMNS: Column[] = [{ + ...COLUMNS[0]!, + className: "max-w-0", + cell: (obs) => , +}]; + function renderObserverCard(obs: ObserverSummary) { const status = deriveObserverStatus(obs); return ( @@ -95,7 +104,8 @@ function renderObserverCard(obs: ObserverSummary) { ); } -export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, onAnalyzePacket, onViewStats }: ObserverTableProps) { +export function ObserverTable({ compact = false, wsManager, selectedObserverId, onSelectObserver, onAnalyzePacket, onViewStats }: ObserverTableProps) { + const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); @@ -110,6 +120,7 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, const { data: brokers } = useQuery({ queryKey: ["brokers"], queryFn: getBrokers, + enabled: !compact, staleTime: 60_000, }); @@ -164,19 +175,19 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver, patchInfinitePages(old, (items) => patchObserverSummary(items, data) ?? items), ); // refresh detail panel if it's showing this observer - if (selectedObserverId === data.observerId) { + if (!compact && selectedObserverId === data.observerId) { queryClient.invalidateQueries({ queryKey: ["observer", data.observerId] }); } }, - [queryClient, queryKey, selectedObserverId], + [queryClient, queryKey, selectedObserverId, compact], ); useWsObserverStatusHandler(wsManager, handleObserverStatus); return ( -
+
-
: + />} o.id} selectedKey={selectedObserverId} - onSelect={onSelectObserver} + onSelect={id => { if (!compact || id !== null) onSelectObserver(id); }} isLoading={isLoading} emptyLabel="No observers" defaultSort={{ header: "Name" }} @@ -208,7 +219,7 @@ export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver,
- {selectedObserverId && ( + {selectedObserverId && !compact && ( onSelectObserver(null)} diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts new file mode 100644 index 00000000..39a7c7c3 --- /dev/null +++ b/src/features/observers/observer-navigation.ts @@ -0,0 +1,12 @@ +import type { StatsRange } from "../stats/types"; + +export const observerRange = (value: string | null): StatsRange => value === "24h" ? "24h" : "3d"; + +export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { + const next = new URLSearchParams(params); + next.set("tab", "Observers"); + for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation", "route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); + if (id) { next.set("observer", id); next.set("range", range); } + else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); } + return next; +} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 18a7fb3b..ec0ec763 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { CloseButton } from "../../components/CloseButton"; import { CopyLinkButton } from "../../components/CopyLinkButton"; import type { PacketDetail } from "../../types/api"; @@ -15,6 +16,8 @@ import { PayloadBreakdown } from "./payload-renderers"; import { ObservationCard } from "./ObservationCard"; import { PathData } from "./PathData"; import { buildPacketPaths } from "../map/packet-path"; +import { PacketInvestigation } from "./PacketInvestigation"; +import { reportSelection } from "./packet-investigation"; function decodePayloadHex(encoded: string): string | null { try { @@ -33,32 +36,41 @@ interface PacketAnalyzerDrawerProps { onClose: () => void; onSelectObservation?: (id: number) => void; onViewNode?: (nodeId: string) => void; - onViewPath?: () => void; + onViewPath?: (key?: string) => void; + onViewObserver?: (id: string) => void; loading?: boolean; + requireSelectedObservation?: boolean; + syncUrl?: boolean; } // side panel (full-screen on mobile) showing packet structure and payload breakdown -export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, loading }: PacketAnalyzerDrawerProps) { - const [, setSearchParams] = useSearchParams(); +export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, onSelectObservation, onViewNode, onViewPath, onViewObserver, loading, requireSelectedObservation = false, syncUrl = true }: PacketAnalyzerDrawerProps) { + const [searchParams, setSearchParams] = useSearchParams(); + const { t } = useTranslation(); const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); + const observerCount = new Set(detail?.observations.map(o => o.observerId)).size; // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { - setSearchParams((p) => { + if (syncUrl) setSearchParams((p) => { const n = new URLSearchParams(p); n.delete("analyze"); return n; }, { replace: true }); onClose(); - }, [setSearchParams, onClose]); + }, [setSearchParams, onClose, syncUrl]); - const selectedObs = detail?.observations.find((o) => o.id === selectedObservationId) - ?? detail?.observations[0] - ?? null; + const urlMatches = syncUrl && detail != null && searchParams.get("hash")?.toLowerCase() === detail.packetHash.toLowerCase(); + const selectionParams = requireSelectedObservation && selectedObservationId != null ? new URLSearchParams({ observation: String(selectedObservationId) }) : urlMatches ? searchParams : new URLSearchParams(); + const { selected: selectedObs, unavailable } = reportSelection(detail?.observations ?? [], selectionParams, selectedObservationId); + const selectReport = (id: number) => { + if (urlMatches) setSearchParams(prev => { const next = new URLSearchParams(prev); next.set("observation", String(id)); return next; }, { replace: true }); + onSelectObservation?.(id); + }; - const rawHex = detail ? buildObservationFrame(detail, selectedObs) : ""; + const rawHex = detail && !unavailable ? buildObservationFrame(detail, selectedObs) : ""; const totalBytes = rawHex.length / 2; const fieldRanges = detail @@ -72,7 +84,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
Packet Analyzer
- {detail && } + {detail && ({ tab: "Packets", route: null, routeIata: null, routeRange: null, routeSince: null, routeUntil: null, hash: detail.packetHash, analyze: "1", path: null, node: null, observer: null, observerId: null, statsTab: null, compareWith: null, compareUntil: null, observation: selectedObs ? String(selectedObs.id) : null })} label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPacket")} />}
@@ -99,39 +111,42 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.scope && {detail.scope}} - ×{detail.observations.length} + ×{observerCount}
- First + {t("investigation.first")} · - Last + {t("investigation.last")} · - Propagation {formatPropagation(detail.firstToLastMs)} + {t("investigation.span")} {formatPropagation(detail.firstToLastMs)}
+ {unavailable &&

{t("investigation.unavailableReport")}

} + +
@@ -149,7 +164,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o key={obs.id} observation={obs} selected={selectedObs?.id === obs.id} - onClick={onSelectObservation ? () => onSelectObservation(obs.id) : undefined} + onClick={() => selectReport(obs.id)} onViewNode={onViewNode} isTrace={detail.header.payloadType === PayloadType.TRACE} /> diff --git a/src/features/packets/PacketAnalyzerOverlay.tsx b/src/features/packets/PacketAnalyzerOverlay.tsx deleted file mode 100644 index ec10bf85..00000000 --- a/src/features/packets/PacketAnalyzerOverlay.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { useEffect, useState } from "react"; -import type { PacketDetail } from "../../types/api"; -import { PacketAnalyzerDrawer } from "./PacketAnalyzerDrawer"; -import { NodeDetailOverlay } from "../nodes/NodeDetailOverlay"; -import { ModalOverlay } from "../../components/ModalOverlay"; - -// Packet analyzer floated over a node detail panel (mirror of NodeDetailOverlay). The node detail it -// can stack on top gets no onAnalyzePacket, so the overlay chain stops there instead of recursing. -export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver, onViewPath, inactive = false }: { - detail: PacketDetail | undefined; - loading?: boolean; - onClose: () => void; - onViewObserver: (observerId: string) => void; - onViewPath?: () => void; - inactive?: boolean; -}) { - const [selectedObservationId, setSelectedObservationId] = useState(null); - const [viewNodeId, setViewNodeId] = useState(null); - - useEffect(() => { - function onKey(e: KeyboardEvent) { - // peel back one layer at a time: the nested node overlay handles its own Escape, so only - // close the analyzer once nothing is stacked above it - if (e.key === "Escape" && !viewNodeId && !inactive) onClose(); - } - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [onClose, viewNodeId, inactive]); - - return ( - <> - - - - {viewNodeId && ( - setViewNodeId(null)} - onViewObserver={onViewObserver} - /> - )} - - ); -} diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 69788392..cd9e5dfb 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,6 +1,6 @@ -import type { PacketSummary } from "../../types/api"; +import type { PacketSummary, ResolvedHop } from "../../types/api"; import { PayloadType, type PathConfidence } from "../../types/enums"; -import { buildPathSummary, type PathChip } from "./path-summary"; +import { HopPopover } from "./PathData"; // Same three-state vocabulary PathData uses in the analyzer. const CONFIDENCE_CLASSES: Record = { @@ -9,35 +9,31 @@ const CONFIDENCE_CLASSES: Record = { none: "bg-text-muted/8 text-text-dim", }; -function Chip({ chip }: { chip: PathChip }) { - if (chip.kind === "hex") { - return {chip.label}; - } - if (chip.kind === "unresolved-run") { - return ( - - {chip.count === 1 ? "?" : `?×${chip.count}`} - - ); - } +function Chip({ hop }: { hop: ResolvedHop }) { + const node = hop.nodes[0]; + const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?"; return ( - - {chip.label} - + + + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} + + ); } const Na = () => n/a; -// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them -// nil on the REST list; there the server's summary (advert name, trace tag, ack checksum) stands in. +// Keep every candidate: a short endpoint hash can match several nodes. export function PacketEndpoints({ packet }: { packet: PacketSummary }) { - const { source, destination } = buildPathSummary(packet); + const source = packet.latestObserver?.resolvedSource; + const destination = packet.latestObserver?.resolvedDestination; // An advert is the node announcing itself, so its name is the whole story — no destination. if (packet.payloadType === PayloadType.ADVERT) { - const self = source ?? (packet.summary ? { kind: "node" as const, label: packet.summary, confidence: "high" as const } : null); - return self ? : ; + if (source) return ; + return packet.summary + ? {packet.summary} + : ; } if (!source && !destination) { @@ -48,9 +44,9 @@ export function PacketEndpoints({ packet }: { packet: PacketSummary }) { return ( - {source ? : } + {source ? : } → - {destination ? : } + {destination ? : } ); } diff --git a/src/features/packets/PacketExpansion.tsx b/src/features/packets/PacketExpansion.tsx index 17374f2c..cb15a00d 100644 --- a/src/features/packets/PacketExpansion.tsx +++ b/src/features/packets/PacketExpansion.tsx @@ -18,7 +18,7 @@ const ACTION_BUTTON_CLASS = interface Props { packet: PacketSummary; - onOpenAnalyzer: () => void; + onOpenAnalyzer: (observationId?: number) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -43,7 +43,7 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb const handleSelectObservation = useCallback( (id: number) => { onSelectObservation(id); - onOpenAnalyzer(); + onOpenAnalyzer(id); }, [onSelectObservation, onOpenAnalyzer], ); diff --git a/src/features/packets/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx new file mode 100644 index 00000000..6ab5d39c --- /dev/null +++ b/src/features/packets/PacketInvestigation.tsx @@ -0,0 +1,49 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Timestamp } from "../../components/Timestamp"; +import type { PacketDetail } from "../../types/api"; +import { PayloadType } from "../../types/enums"; +import { groupPacketReports } from "./packet-investigation"; +import { buildPacketPaths } from "../map/packet-path"; + +const actionClass = "min-h-9 rounded border border-border px-2 text-xs text-primary hover:bg-bg-raised disabled:opacity-40 disabled:cursor-not-allowed"; + +export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserver, onViewPath }: { + detail: PacketDetail; selectedId: number | null; onSelect: (id: number) => void; + onViewObserver?: (id: string) => void; onViewPath?: (key?: string) => void; +}) { + const { t } = useTranslation(); + const groups = useMemo(() => groupPacketReports(detail.observations), [detail.observations]); + const mapped = useMemo(() => new Set(buildPacketPaths(detail).map(p => p.key)), [detail]); + const observerCount = new Set(detail.observations.map(o => o.observerId)).size; + const isTrace = detail.header.payloadType === PayloadType.TRACE; + const [showAll, setShowAll] = useState(false); + const visible = showAll ? groups : groups.filter((group, index) => index < 3 || group.reports.some(o => o.id === selectedId)); + return
+

{t("investigation.title")}

+

{t("investigation.reports", { count: detail.observations.length })} · {t("investigation.observers", { count: observerCount })}

+

{t(isTrace ? "investigation.traceNote" : "investigation.pathNote")}

+ {groups.length === 0 ?

{t("investigation.empty")}

: visible.map(group =>
o.id === selectedId)} className="rounded border border-border bg-bg-base p-2"> + + {t(group.kind === "empty" ? "investigation.noEntries" : group.kind === "unavailable" ? "investigation.unavailablePath" : isTrace ? "investigation.tracePath" : "investigation.path", { number: groups.indexOf(group) + 1 })} + ({t("investigation.reports", { count: group.reports.length })}) + {group.hashes.length > 0 && {group.hashes.join(" → ")}} + +
    {group.reports.map(report => { + const key = isTrace ? "trace" : report.observerId; + const canMap = mapped.has(key); + return
  • +

    {report.observerName ?? report.observerId.slice(0, 8)} · {report.iata || "—"} ·

    +
    + + {onViewObserver && } + +
    + {!canMap &&

    {t("investigation.unmappable")}

    } +
  • ; + })}
+
)} + {groups.length > 3 && } +

{t("investigation.retention")}

+
; +} diff --git a/src/features/packets/PacketList.tsx b/src/features/packets/PacketList.tsx index 2321faa8..3d179b50 100644 --- a/src/features/packets/PacketList.tsx +++ b/src/features/packets/PacketList.tsx @@ -32,7 +32,7 @@ const ROUTE_OPTIONS = Object.entries(ROUTE_TYPE_NAMES).map(([value, label]) => ( interface PacketListProps { wsManager: WsManager; - onAnalyze: (hash: string | null) => void; + onAnalyze: (hash: string | null, observationId?: number) => void; onViewPath: (detail: PacketDetail) => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; @@ -87,6 +87,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati const next = expandedHash === hash ? null : hash; setSearchParams((p) => { const n = new URLSearchParams(p); + n.delete("observation"); if (next) n.set("hash", next); else n.delete("hash"); return n; }, { replace: true }); @@ -96,8 +97,10 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati const { data: expandedDetail, isError: selectedError, error: detailError, refetch: retryDetail } = usePacketDetail(expandedHash); const detailStatus = detailError && "status" in detailError ? detailError.status : undefined; - const handleOpenAnalyzer = useCallback(() => { - if (expandedHash) onAnalyze(expandedHash); + const handleOpenAnalyzer = useCallback((observationId?: number) => { + if (!expandedHash) return; + if (observationId == null) onAnalyze(expandedHash); + else onAnalyze(expandedHash, observationId); }, [expandedHash, onAnalyze]); const handleViewPath = useCallback(() => { @@ -192,7 +195,7 @@ export function PacketList({ wsManager, onAnalyze, onViewPath, selectedObservati ) : expandedDetail ? ( <>

Selected packet is outside the loaded results.

- + ) :

Loading selected packet…

}
diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index 0ee94913..fbd2497e 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -63,7 +63,9 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp {packet.scope && ( <> · - {packet.scope} + + {packet.scope} + )} · diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 95a430f2..3cfa8870 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -15,11 +15,12 @@ interface PacketTableRowProps { onToggle: () => void; } -// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the +// Fixed-height table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the // expansion instead, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { // ?? not ||, so a legitimate 0-hop direct packet still shows its count const pathLength = packet.latestObserver?.pathLength; + const routeName = packet.routeTypeName || "Unknown"; const na = n/a; return ( @@ -36,7 +37,7 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa type="button" onClick={onToggle} aria-expanded={expanded} - className="grid w-full items-center gap-x-3 px-3 py-1.5 text-left text-[11px] cursor-pointer" + className="grid h-9 w-full items-center gap-x-3 px-3 py-1 text-left text-[11px] cursor-pointer" style={{ gridTemplateColumns: GRID_TEMPLATE }} > @@ -50,9 +51,9 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa {PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName} - - {packet.routeTypeName || "Unknown"} - {packet.scope && {packet.scope}} + + {routeName} + {packet.scope && {packet.scope}} ×{packet.observationCount} {pathLength?.hopCount ?? na} diff --git a/src/features/packets/PacketVirtualList.tsx b/src/features/packets/PacketVirtualList.tsx index e2d64a2c..6fd1d590 100644 --- a/src/features/packets/PacketVirtualList.tsx +++ b/src/features/packets/PacketVirtualList.tsx @@ -23,7 +23,7 @@ interface PacketVirtualListProps { expandedHash: string | null; onToggleExpand: (hash: string) => void; // only the expanded row renders an expansion, so these need no hash argument - onOpenAnalyzer: () => void; + onOpenAnalyzer: (observationId?: number) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void; diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 90f20e1a..59db7f9e 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -17,7 +17,7 @@ function nodeLabel(node: ResolvedNode): string { } // Portals to so the drawer's overflow doesn't clip it; a close delay bridges the mouse gap. -function HopPopover({ hop, onViewNode, showSnr = true, children }: { +export function HopPopover({ hop, onViewNode, showSnr = true, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; showSnr?: boolean; @@ -94,7 +94,8 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} onClick={hasHover ? undefined : toggle} - className="inline-flex" + title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"} + className="inline-flex min-w-0 max-w-full" > {children} {anchor && diff --git a/src/features/packets/packet-grid.ts b/src/features/packets/packet-grid.ts index c077378b..503a843e 100644 --- a/src/features/packets/packet-grid.ts +++ b/src/features/packets/packet-grid.ts @@ -4,4 +4,4 @@ // ("HASH" vs "4AE77F09") — either silently drifts the two apart. // Endpoints take minmax(0,1fr) so they absorb every bit of squeeze and truncate, rather than // pushing IATA and Age off the edge on a narrow viewport. -export const GRID_TEMPLATE = "1.25rem 5rem 6rem 5rem 3rem 3rem 4rem minmax(0,1fr) 3.5rem 5rem"; +export const GRID_TEMPLATE = "1.25rem 5rem 6rem 8rem 3rem 3rem 4rem minmax(0,1fr) 3.5rem 5rem"; diff --git a/src/features/packets/packet-investigation.ts b/src/features/packets/packet-investigation.ts new file mode 100644 index 00000000..2ac2a195 --- /dev/null +++ b/src/features/packets/packet-investigation.ts @@ -0,0 +1,39 @@ +import type { Observation } from "../../types/api"; + +export interface ReportGroup { + key: string; + kind: "path" | "empty" | "unavailable"; + hashes: string[]; + reports: Observation[]; +} + +// Equal prefixes describe equal recorded bytes, not proof of the same physical route. +export function groupPacketReports(reports: Observation[]): ReportGroup[] { + const groups = new Map(); + for (const report of [...reports].sort((a, b) => a.heardAt - b.heardAt || a.id - b.id)) { + const width = report.pathLength?.hashSize; + const count = report.pathLength?.hopCount; + const raw = (report.pathBytes ?? "").toUpperCase(); + const valid = [1, 2, 3].includes(width) && Number.isInteger(count) && count >= 0 && raw.length === width * count * 2 && /^[0-9A-F]*$/.test(raw); + const kind = !valid ? "unavailable" : count === 0 ? "empty" : "path"; + const key = valid ? `${width}:${count}:${raw}` : `unavailable:${report.id}`; + let group = groups.get(key); + if (!group) { + const hashes: string[] = []; + if (valid) for (let i = 0; i < raw.length; i += width * 2) hashes.push(raw.slice(i, i + width * 2)); + group = { key, kind, hashes, reports: [] }; groups.set(key, group); + } + group.reports.push(report); + } + return [...groups.values()]; +} + +export function reportSelection(reports: Observation[], params: URLSearchParams, fallback: number | null) { + const requested = params.get("observation"); + if (requested != null) { + const id = Number(requested); + const selected = params.getAll("observation").length === 1 && /^[1-9]\d*$/.test(requested) && Number.isSafeInteger(id) ? reports.find(o => o.id === id) ?? null : null; + return { selected, unavailable: selected == null }; + } + return { selected: reports.find(o => o.id === fallback) ?? reports[0] ?? null, unavailable: false }; +} diff --git a/src/features/packets/path-summary.ts b/src/features/packets/path-summary.ts index 8df354d6..3fbcf018 100644 --- a/src/features/packets/path-summary.ts +++ b/src/features/packets/path-summary.ts @@ -15,12 +15,10 @@ export interface PathSummary { hopLabel: string; chips: PathChip[]; overflow: number; // hops not represented by a visible chip - source: PathChip | null; - destination: PathChip | null; isNa: boolean; } -const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, source: null, destination: null, isNa: true }; +const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, isNa: true }; function hopChip(hop: ResolvedHop): PathChip | null { if (hop.confidence === "none") return null; @@ -29,12 +27,6 @@ function hopChip(hop: ResolvedHop): PathChip | null { return { kind: "node", label, confidence: hop.confidence }; } -// Endpoints are single hops and never collapse — an unresolved one shows "?" rather than a run. -function endpointChip(hop: ResolvedHop | undefined): PathChip | null { - if (!hop) return null; - return hopChip(hop) ?? { kind: "unresolved-run", count: 1 }; -} - function chipsFromResolved(path: ResolvedHop[]): PathChip[] { const out: PathChip[] = []; for (const hop of path) { @@ -63,9 +55,7 @@ export function buildPathSummary(packet: PacketSummary): PathSummary { if (!observer || !length) return NA; const { hopCount, hashSize } = length; - const source = endpointChip(observer.resolvedSource); - const destination = endpointChip(observer.resolvedDestination); - const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, source, destination, isNa: false }; + const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, isNa: false }; if (hopCount === 0) return { ...base, hopLabel: "0 hops · direct" }; diff --git a/src/features/routes/RouteDetailPanel.tsx b/src/features/routes/RouteDetailPanel.tsx index 6025d971..3402d628 100644 --- a/src/features/routes/RouteDetailPanel.tsx +++ b/src/features/routes/RouteDetailPanel.tsx @@ -3,6 +3,7 @@ import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; import { ResolvedHopBlock } from "../packets/PathData"; import type { KnownRoute, ResolvedHop } from "../../types/api"; +import { useTranslation } from "react-i18next"; // Detail for a selected known route. The /routes list already carries the full hops, so this takes the // route object directly — no extra fetch. Hops reuse the packet path renderer's block (high-confidence @@ -13,9 +14,11 @@ interface RouteDetailPanelProps { } export function RouteDetailPanel({ route, onClose }: RouteDetailPanelProps) { + const { t } = useTranslation(); return (
+ {!route.pathKey &&

{t("routeEvidence.legacy")}

}
{route.iata} diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx new file mode 100644 index 00000000..5b270f01 --- /dev/null +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -0,0 +1,71 @@ +import { useState } from "react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { getRouteEvidence, isNotFound } from "../../api/client"; +import { DetailPanel, Section } from "../../components/DetailPanel"; +import { CopyLinkButton } from "../../components/CopyLinkButton"; +import { Timestamp } from "../../components/Timestamp"; +import { Segmented } from "../stats/Segmented"; +import { ResolvedHopBlock } from "../packets/PathData"; + +const actionClass = "min-h-9 rounded border border-border px-2 text-xs text-primary hover:bg-bg-raised disabled:opacity-40 disabled:cursor-not-allowed"; + +export interface RouteActions { + onAnalyzePacket?: (hash: string, observationId: number) => void; + onViewObserver?: (id: string) => void; + onViewNode?: (id: string) => void; +} + +export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, onViewObserver, onViewNode }: RouteActions & { iata: string; pathKey: string; onClose: () => void }) { + const { t } = useTranslation(); + const [params, setParams] = useSearchParams(); + const [openedAt] = useState(Date.now); + const range = ["3d", "7d", "30d"].includes(params.get("routeRange") ?? "") ? "3d" : "24h"; + const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil"); + const fixed = rawSince !== null || rawUntil !== null; + const since = Number(rawSince), until = Number(rawUntil); + const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 3 * 86400000); + const query = useInfiniteQuery({ + queryKey: ["route-evidence", iata, pathKey, range, rawSince, rawUntil], + queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "3d" ? "72h" : "24h", limit: 50 }, signal), + initialPageParam: undefined as string | undefined, + getNextPageParam: (last, pages) => pages.length < 10 && last.hasMore ? last.nextPageCursor : undefined, + enabled: !invalid, + staleTime: Infinity, + refetchOnWindowFocus: false, + retry: false, + }); + const first = query.data?.pages[0]; + const reports = query.data?.pages.flatMap(page => page.items) ?? []; + const capped = (query.data?.pages.length ?? 0) >= 10 && query.data?.pages.at(-1)?.hasMore; + return ({ tab: "Routes", route: pathKey, routeIata: iata, routeRange: range, routeSince: String(first.windowStart), routeUntil: String(first.windowEnd), hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}> +
+ setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} /> +

{t("observerCompare.retainedWindow")}

+ {fixed &&

{t("routeEvidence.shared")}

} + {first &&

{new Date(first.windowStart).toISOString().slice(0, 19).replace("T", " ")} → {new Date(first.windowEnd).toISOString().slice(0, 19).replace("T", " ")} UTC

} +
+ {invalid ?

{t("routeEvidence.invalid")}

: query.isPending ?

{t("routeEvidence.loading")}

: <> + {query.isError &&

{t(isNotFound(query.error) ? "routeEvidence.missing" : "routeEvidence.error")}

} + {first && <> +
+

{t("routeEvidence.width", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

+
    {first.route.hops.map((hop, index) =>
  1. {index + 1}.{hop.node?.name ?? "—"}
  2. )}
+
+
+

{t("routeEvidence.brief")}

+
{t("routeEvidence.definition")}

{t("routeEvidence.match", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

{t("routeEvidence.caution")}

{t("routeEvidence.retention")}

+ {!first.matchAvailable ?

{t("routeEvidence.unavailable")}

: reports.length === 0 ?

{t("routeEvidence.empty")}

:
    {reports.map(report =>
  • +
    {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}
    +

    {report.observerName ?? report.observerId.slice(0, 8)}

    +

    · SNR {report.snr == null ? "—" : `${report.snr} dB`} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

    +
    {onAnalyzePacket && }{onViewObserver && }
    +
  • )}
} + {query.hasNextPage && } + {capped &&

{t("routeEvidence.cap")}

} +
+ } + } +
; +} diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index a92b051d..9ba9e45c 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -1,7 +1,9 @@ import { useState, useMemo, useCallback, useEffect, useRef, memo } from "react"; import { useQuery } from "@tanstack/react-query"; +import { useSearchParams } from "react-router-dom"; +import { RouteEvidencePanel, type RouteActions } from "./RouteEvidencePanel"; import { getKnownRoutesPage, searchKnownRoutes, searchCrossIATARoutes, getIatas } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { useRegion, useRegionSelection } from "../../hooks/useRegion"; import { useInfinitePages } from "../../hooks/useInfinitePages"; import { Badge } from "../../components/Badge"; import { Timestamp } from "../../components/Timestamp"; @@ -140,19 +142,27 @@ function directedPairs(iatas: string[]): [string, string][] { return pairs; } -export function RouteTable() { - const { iatas, regionKey } = useRegion(); - +export function RouteTable(actions: RouteActions) { + const { iatas } = useRegion(); + const { selection } = useRegionSelection(); + const [params, setParams] = useSearchParams(); const [selectedKey, setSelectedKey] = useState(null); + const pathKey = params.get("route"), routeIata = params.get("routeIata"); + const closeRoute = useCallback(() => { + setSelectedKey(null); + setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); return next; }, { replace: true }); + }, [setParams]); + + // drop the selection when the region changes — the selected route may not be in the new region - const prevRegion = useRef(regionKey); + const prevRegion = useRef(selection); useEffect(() => { - if (prevRegion.current !== regionKey) { - prevRegion.current = regionKey; - setSelectedKey(null); + if (prevRegion.current !== selection) { + prevRegion.current = selection; + closeRoute(); } - }, [regionKey]); + }, [selection, closeRoute]); // path search form: source→dest hashes, scoped to a multi-select of IATAs. One IATA → within-IATA // /routes/search; two+ → /routes/cross across the directed pairs. Hashes + ≥1 IATA required. @@ -223,6 +233,18 @@ export function RouteTable() { [rows, selectedKey], ); + const selectRoute = (id: string | null) => { + if (id === null) { closeRoute(); return; } + const route = rows?.find(row => String(row.id) === id); + if (route?.pathKey) { + setSelectedKey(null); + setParams(previous => { const next = new URLSearchParams(previous); next.set("route", route.pathKey!); next.set("routeIata", route.iata); next.delete("routeSince"); next.delete("routeUntil"); return next; }, { replace: true }); + } else { + closeRoute(); + setSelectedKey(id); + } + }; + // A multi-IATA region filters globally-paged rows client-side, so the filtered list can be too // short to ever trigger scroll paging — or empty, with the region's routes deeper in the cursor // stream. Keep pulling pages until there's a screenful or the cap says the region is just sparse. @@ -233,26 +255,27 @@ export function RouteTable() { loadMore(); }, [search, serverIata, iatas, hasMore, isPaging, rows, loadedCount, loadMore]); + const panelOpen = Boolean(pathKey && routeIata || selectedRoute); const canSearch = !!(from.trim() && to.trim() && searchIatas.length >= 1); // clear any selection when the visible list changes out from under it (search submit/clear) const submitSearch = useCallback(() => { if (!from.trim() || !to.trim() || searchIatas.length < 1) return; setSearch({ from: from.trim(), to: to.trim(), iatas: searchIatas }); - setSelectedKey(null); - }, [from, to, searchIatas]); + closeRoute(); + }, [from, to, searchIatas, closeRoute]); const clearSearch = useCallback(() => { setSearch(null); - setSelectedKey(null); - }, []); + closeRoute(); + }, [closeRoute]); const onKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") submitSearch(); }; return ( -
+
{/* stacks into two rows on mobile (the inputs would otherwise wrap around the arrow); one row at md+ */} -
+
Find path ) : ( -
+
String(r.id)} - selectedKey={selectedKey} - onSelect={setSelectedKey} + selectedKey={pathKey ? String(rows?.find(row => row.pathKey === pathKey && row.iata === routeIata)?.id ?? "") : selectedKey} + onSelect={selectRoute} isLoading={search ? searchLoading : listLoading} emptyLabel={search ? "No matching routes" : "No routes"} defaultSort={{ header: "Last seen", direction: "desc" }} @@ -329,7 +352,7 @@ export function RouteTable() { )}
)} - {selectedRoute && ( + {pathKey && routeIata ? : selectedRoute && ( setSelectedKey(null)} /> )}
diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 7605afbf..0f3a6b64 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type FormEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; import { Card } from "./cards"; @@ -16,6 +17,7 @@ function validation(value: Selection): string | null { if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { return "Choose a valid start and a later end time."; } + if (value.until - value.since > 3 * 86_400_000) return "observerCompare.windowLimit"; return null; } @@ -61,6 +63,7 @@ function ObserverSelect({ label, value, onChange }: { label: string; value: stri } function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onCompare: (value: Selection) => void }) { + const { t } = useTranslation(); const [a, setA] = useState(initial?.observerA ?? ""); const [b, setB] = useState(initial?.observerB ?? ""); const [since, setSince] = useState(() => localTime(initial?.since ?? Math.floor((Date.now() - 86_400_000) / 60_000) * 60_000)); @@ -85,13 +88,14 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC setUntil(e.target.value)} className={fieldClass} />
- {error &&

{error}

} + {error &&

{t(error, { defaultValue: error })}

} ); } export function CompareObserversTab() { + const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const [params, setParams] = useSearchParams(); const keys = ["compareA", "compareB", "compareSince", "compareUntil"]; @@ -137,7 +141,7 @@ export function CompareObserversTab() { return (
-

Compare distinct flood packets reported by two observers over any retained time period.

+

{t("observerCompare.retainedWindow")}

{supplied && !valid &&

This comparison link has invalid or missing values. Choose observers and dates below.

} params.get(key)).join("|")} initial={selection} onCompare={compare} />
diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index c601641f..7a0cc23d 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,9 +1,10 @@ +import { useTranslation } from "react-i18next"; import { useMemo } from "react"; import { formatCount } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; -import { Card, ChartCard, StatCard } from "./cards"; +import { ChartCard, StatCard } from "./cards"; import { useLiveOverview } from "./useLiveStats"; import { aggregatePresets, formatPreset, payloadBarItems } from "./transforms"; import type { WsManager } from "../../api/ws-manager"; @@ -35,6 +36,7 @@ interface MeshTabProps { } export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { + const { t } = useTranslation(); const colors = useChartColors(); useLiveOverview(wsManager); const overview = useStatsOverview(); @@ -141,7 +143,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
{/* range-driven charts lead the grid; the all-time ones follow below */} - Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> +
+ Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> +
Payload types · {range}} right={{formatCount(payloadData === undefined ? undefined : payloadTotal)} obs} @@ -156,7 +160,9 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { - Scopes · selected region · retained data}> +
+ {t("stats.tabs.scopes")} +
{scopes.isError ? (
Failed to load
) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? ( @@ -185,7 +191,8 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { )} - +
+
); diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index a90fe0a6..c101d383 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,200 +1,64 @@ -import { useEffect, useMemo, useState } from "react"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Badge } from "../../components/Badge"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { EmptyState } from "../../components/EmptyState"; -import { formatBattery, formatCount, formatRadioParts, formatUptime } from "../../lib/formatters"; -import { getObserversPage } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { formatRadioParts } from "../../lib/formatters"; +import { ObserverComparison } from "../observers/ObserverComparison"; +import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; import { useChartColors } from "./chartTheme"; -import { useTopObservers } from "./useStats"; import { activityParamsFor, useObserver, useObserverActivity, useObserverTelemetry } from "./useTelemetry"; -import { useTick } from "../../hooks/useTick"; -import { deriveObserverStatus } from "../observers/observer-status"; -import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption, typeBarOption } from "./chartOptions"; +import { + airtimeOption, + batteryOption, + noiseFloorOption, + queueOption, + receiveErrorsOption, + busyOption, + heardOption, + snrHeardOption, + typeBarOption, +} from "./chartOptions"; import { Card, ChartCard } from "./cards"; import { fillActivity, hasTelemetry, intervalToMs, latestAirtimePct, payloadBarItems } from "./transforms"; import { useLiveObserver } from "./useLiveStats"; import { isNotFound } from "../../api/client"; import type { WsManager } from "../../api/ws-manager"; -import type { Observer } from "../observers/types"; import { RANGE_MS, type StatsRange } from "./types"; -// how the "Heard per …" title reads for each bucket the activity endpoint returns -const PER_BUCKET: Record = { "5m": "5 min", "15m": "15 min", "1h": "hour", "6h": "6 h", "24h": "day" }; - -type AirtimePct = { rx: number | null; tx: number | null }; - -function airtimeLabel(a: AirtimePct): string { +function airtimeLabel(a: { rx: number | null; tx: number | null }): string { return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); } - -function ObserverList({ - range, - selectedId, - onSelect, -}: { - range: StatsRange; - selectedId: string | null; - onSelect: (id: string) => void; -}) { - const { iatas, regionKey } = useRegion(); - const [query, setQuery] = useState(""); - // debounce so the server-side lookup fires once per pause, not once per keystroke - const [q, setQ] = useState(""); - useEffect(() => { - const id = setTimeout(() => setQ(query.trim()), 250); - return () => clearTimeout(id); - }, [query]); - const searching = q.length > 0; - - // default: top observers by activity (with count + activity bar). Searching swaps to a server-side - // name lookup across ALL observers in the region, not just the loaded top rows. - const top = useTopObservers(range, 15); - const max = useMemo(() => Math.max(1, ...(top.data ?? []).map((o) => o.observationCount)), [top.data]); - - const search = useQuery({ - queryKey: ["observer-search", regionKey, q], - queryFn: () => getObserversPage(iatas, { name: q, limit: 50 }), - enabled: searching, - staleTime: 30_000, - placeholderData: keepPreviousData, - }); - - type Row = { id: string; name: string; count?: number; iata?: string; online?: boolean }; - const rows: Row[] = searching - ? (search.data?.items ?? []).map((o) => ({ id: o.id, name: o.displayName ?? o.id.slice(0, 8), iata: o.iata, online: o.status === "online" })) - : (top.data ?? []).map((o) => ({ id: o.observerId, name: o.displayName ?? o.observerId.slice(0, 8), count: o.observationCount })); - - const loading = searching ? search.isLoading : top.isLoading; - - return ( - - setQuery(e.target.value)} - placeholder="Search observers…" - className="mb-2 w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-[12px] text-text-normal placeholder:text-text-dim" - /> -
- {loading &&
{searching ? "Searching…" : "Loading…"}
} - {searching && search.isError && ( -
Search failed
- )} - {!loading && !(searching && search.isError) && rows.length === 0 && ( -
{searching ? "No matches" : "No observers"}
- )} - {rows.map((r) => { - const active = r.id === selectedId; - return ( - - ); - })} -
-
- ); -} - -function ObserverHeader({ observer, airtime }: { observer: Observer; airtime: AirtimePct }) { - useTick(); // keep the recency-derived status badge fresh - const status = deriveObserverStatus(observer); - const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); - - return ( - - {observer.displayName ?? observer.id.slice(0, 8)} - {status} - {observer.observerType && {observer.observerType}} - - } - right={ - {observer.iata} - } - > -
- - - - {(airtime.rx != null || airtime.tx != null) && } - {radio && } -
-
- ); -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ); -} - interface ObserverTabProps { range: StatsRange; selectedObserverId: string | null; - onSelectObserver: (observerId: string) => void; + onSelectObserver: (id: string) => void; wsManager: WsManager; + comparison?: { id: string; until: number | null; onSelect: (id: string) => void; onRefresh: () => number }; } - -export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { +export function ObserverTab({ range, selectedObserverId, wsManager, comparison }: ObserverTabProps) { + const { t } = useTranslation(); const colors = useChartColors(); useLiveObserver(wsManager, selectedObserverId, range); - const topObservers = useTopObservers(range, 15); - const observer = useObserver(selectedObserverId); + const observer = useObserver(selectedObserverId, true); const telemetry = useObserverTelemetry(selectedObserverId, range); - const activity = useObserverActivity(selectedObserverId, range); - - // default to the busiest observer once the list loads and nothing is selected - useEffect(() => { - if (selectedObserverId) return; - const first = topObservers.data?.[0]; - if (first) onSelectObserver(first.observerId); - }, [selectedObserverId, topObservers.data, onSelectObserver]); - + const activity = useObserverActivity(selectedObserverId, range, comparison?.until ?? undefined); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); - // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points - const bucketMs = telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; + // Use the response interval so the plotted bucket width always matches the data. + const bucketMs = + telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; const bucketed = bucketMs != null; const airtime = useMemo(() => airtimeOption(points, colors, bucketMs), [points, colors, bucketMs]); - // bucketed values span less than their bucket, so only the hourly series gives an honest header number + // Show the latest airtime percentage only when computed from unbucketed reports. const latestAirtime = useMemo( () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), [points, bucketMs], ); - const battery = useMemo(() => batteryOption(points, colors), [points, colors]); - const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); - const queue = useMemo(() => queueOption(points, colors), [points, colors]); - const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]); + const battery = useMemo(() => batteryOption(points, colors, t("observerPage.battery") + " V"), [points, colors, t]); + const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noise") + " dBm"), [points, colors, t]); + const queue = useMemo(() => queueOption(points, colors, t("observerPage.queue")), [points, colors, t]); + const recvErrors = useMemo( + () => receiveErrorsOption(points, colors, bucketed, t("observerPage.errors")), + [points, colors, bucketed, t], + ); // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. @@ -204,96 +68,201 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) => ready && !points.some((p) => accessors.some((a) => a(p) != null)); - // a 404 means this server has no activity endpoint yet: hide the heard group rather than show failed cards - const heardUnavailable = activity.isError && isNotFound(activity.error); - // a placeholder is the previous selection's data, so treat it as loading rather than read anything from it - const heardLoading = activity.isLoading || activity.isPlaceholderData; - const heardData = activity.isPlaceholderData ? undefined : activity.data; + // A 404 without cached data means this server has no activity endpoint yet. + const heardUnavailable = !activity.data && activity.isError && isNotFound(activity.error); + const heardLoading = activity.isLoading; + const heardData = activity.data; const intervalMs = heardData ? intervalToMs(heardData.interval) : null; // the window ends at the last fetch so the right edge follows now on every poll const heardWindow = useMemo(() => { - const end = activity.dataUpdatedAt; + const end = heardData?.windowEnd ?? activity.dataUpdatedAt; const span = (heardData && intervalToMs(heardData.range)) ?? RANGE_MS[range]; - return { start: end - span, end }; + return { start: heardData?.windowStart ?? end - span, end }; }, [heardData, activity.dataUpdatedAt, range]); const heard = useMemo( () => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow) : []), [heardData, intervalMs, heardWindow], ); - const busy = useMemo(() => busyOption(heard, colors, intervalMs, heardWindow), [heard, colors, intervalMs, heardWindow]); - const heardCount = useMemo(() => heardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); - const snr = useMemo(() => snrHeardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); - const payloadItems = useMemo(() => payloadBarItems(heardData?.payloadTypes ?? []), [heardData]); + const busy = useMemo( + () => busyOption(heard, colors, intervalMs, heardWindow), + [heard, colors, intervalMs, heardWindow], + ); + const heardCount = useMemo( + () => heardOption(heard, colors, heardWindow, t("observerPage.packets")), + [heard, colors, heardWindow, t], + ); + const snr = useMemo( + () => + snrHeardOption(heard, colors, heardWindow, { + average: t("observerPage.mean"), + minimum: t("observerPage.minimum"), + }), + [heard, colors, heardWindow, t], + ); + const payloadItems = useMemo( + () => + payloadBarItems(heardData?.payloadTypes ?? []).map((item) => + item.name.toLowerCase() === "unknown" ? { ...item, name: t("observerPage.unknown") } : item, + ), + [heardData, t], + ); const payload = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const nothingHeard = heardData != null && heardData.points.length === 0; const costed = heardData?.points.some((p) => p.airtimeMs != null) ?? false; const heardSnr = heard.some((p) => p.snrAvg != null); const radioLabel = formatRadioParts(heardData?.radio ?? {}); const interval = heardData?.interval ?? activityParamsFor(range).interval; - const perBucket = PER_BUCKET[interval] ?? interval; + const perBucket = + interval === "1h" + ? t("observerPage.hour") + : interval === "24h" + ? t("observerPage.day") + : interval.replace("m", " min").replace("h", " h"); - return ( -
-
- + if (!selectedObserverId) return ; + if (observer.isError) + return ( +
+

{t("observerPage.loadFailed")}

+
- -
- {!selectedObserverId ? ( - - - - ) : ( - <> - {observer.data && } - {noTelemetry ? ( - - - - ) : ( - <> - Airtime TX / RX · {range}} - height={180} - option={airtime} - isLoading={telemetry.isLoading} - isError={telemetry.isError} - isEmpty={missing((p) => p.airtimeTxSecs, (p) => p.airtimeRxSecs)} - /> -
- p.batteryMv)} /> - p.noiseFloorDb)} /> - p.queueLength)} /> - p.receiveErrors)} /> -
- + ); + if (!observer.data) + return ( +

+ {t("observerPage.recording")} +

+ ); + return ( +
+ + {comparison && { + const nextUntil = comparison.onRefresh(); + void observer.refetch(); + void telemetry.refetch(); + if (nextUntil === comparison.until) void activity.refetch(); + return nextUntil; + }} />} + {heardUnavailable ? ( + +

{t("observerPage.summaryMissing")}

+
+ ) : nothingHeard ? ( + + + + ) : ( + <> +
+ + + +
+ {activity.isError && ( + + )} + + )} +

{t("observerPage.device")}

+ {noTelemetry ? ( + +

{t("observerPage.noTelemetryHelp")}

+
+ ) : ( + <> + {airtimeLabel(latestAirtime)}} + height={200} + option={airtime} + isLoading={telemetry.isLoading} + isError={!telemetry.data && telemetry.isError} + isEmpty={missing( + (p) => p.airtimeTxSecs, + (p) => p.airtimeRxSecs, )} - {!heardUnavailable && - (nothingHeard ? ( - - - - ) : ( - <> - Channel busy · {range}} - right={radioLabel && {radioLabel}} - height={180} - option={busy} - isLoading={heardLoading} - isError={activity.isError} - isEmpty={heardData != null && !costed} - /> -
- - -
- - - ))} - - )} -
-
+ /> +
+ p.batteryMv)} + /> + p.noiseFloorDb)} + /> + p.queueLength)} + /> + p.receiveErrors)} + /> +
+ {telemetry.isError && ( + + )} + + )} + {!heardUnavailable && costed && ( + {radioLabel}} + height={180} + option={busy} + isLoading={heardLoading} + isError={!heardData && activity.isError} + /> + )} + +
); } diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index aa920f98..7e4d198b 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -9,29 +9,29 @@ import { PathsTab } from "./PathsTab"; import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; -import { ObserverTab } from "./ObserverTab"; +import { observerDestination } from "../observers/observer-navigation"; import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; -const RANGES: StatsRange[] = ["24h", "7d", "30d"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; +const RANGES: StatsRange[] = ["24h", "3d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); -const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); +const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "3d"); interface StatsOverviewProps { wsManager: WsManager; + onObserverDashboard?: (id: string) => void; } -// Stats page shell: a sub-header bar (Mesh / Observer pills + range) over the active +// Stats page shell: an analytics sub-header and range over the active // sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. -export function StatsOverview({ wsManager }: StatsOverviewProps) { +export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); - const observerId = params.get("observerId"); const patch = useCallback( (updates: Record) => { @@ -52,7 +52,10 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]); const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]); - const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]); + const handleSelectObserver = useCallback((id: string) => { + if (onObserverDashboard) onObserverDashboard(id); + else setParams(observerDestination(params, id)); + }, [onObserverDashboard, params, setParams]); return (
@@ -65,9 +68,6 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } - {tab === "observer" && ( - - )} {tab === "graph" && } {tab === "compare" && }
diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index aaf201f2..c95357a1 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -34,15 +34,6 @@ function MeshIcon() { ); } -function ObserverIcon() { - return ( - - - - - ); -} - function SignalIcon() { return @@ -95,12 +86,11 @@ const TAB_OPTIONS = [ { value: "scopes", icon: }, { value: "talkers", icon: }, { value: "clockdrift", icon: }, - { value: "observer", icon: }, { value: "compare", icon: }, { value: "graph", icon: }, ]; -const RANGES: StatsRange[] = ["24h", "7d", "30d"]; +const RANGES: StatsRange[] = ["24h", "3d", "30d"]; interface Props { tab: StatsTab; diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index b4154fdb..e03d085d 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -371,18 +371,18 @@ function metricLineOption( }; } -export const batteryOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Battery V", color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); +export const batteryOption = (p: TelemetryPoint[], c: ChartColors, name = "Battery V") => + metricLineOption(p, c, { name, color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); -export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Noise dBm", color: c.warn, accessor: (x) => x.noiseFloorDb }); +export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors, name = "Noise dBm") => + metricLineOption(p, c, { name, color: c.warn, accessor: (x) => x.noiseFloorDb }); -export const queueOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); +export const queueOption = (p: TelemetryPoint[], c: ChartColors, name = "Queue") => + metricLineOption(p, c, { name, color: c.secondary, accessor: (x) => x.queueLength, area: true }); // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones -export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => - metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean, name = "Recv errors") => + metricLineOption(p, c, { name, color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); // ---- Observer activity (what it heard) ---- @@ -425,7 +425,7 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: }; } -export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { +export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, name = "Heard"): EChartsOption { return { animation: false, backgroundColor: "transparent", @@ -435,7 +435,7 @@ export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWind yAxis: valueAxis(c, { minInterval: 1 }), series: [ { - name: "Heard", + name, type: "line", symbol: "none", data: points.map((p) => [p.t, p.observations]), @@ -449,18 +449,18 @@ export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWind const dbLabel = (v: unknown) => (typeof v === "number" ? `${v} dB` : "—"); -export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { +export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, labels = { average: "Avg", minimum: "Min" }): EChartsOption { return { animation: false, backgroundColor: "transparent", grid: { left: 54, right: 14, top: 24, bottom: 22 }, - legend: { data: ["Avg", "Min"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, + legend: { data: [labels.average, labels.minimum], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: dbLabel }, xAxis: windowAxis(c, w), yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), series: [ - { name: "Avg", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, - { name: "Min", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, + { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, + { name: labels.minimum, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, ], }; } diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 07505185..c5ac8537 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -144,7 +144,19 @@ export interface ActivityRadio { preambleSymbols: number | null; } +export interface ObserverActivitySummary { + recordedPackets: number; + lastCompleteHour: number; + lastCompleteHourStart: number; + lastCompleteHourEnd: number; + latestRecordedAt: number | null; +} export interface ObserverActivity { + windowStart?: number; + windowEnd?: number; + generatedAt?: number; + source?: "raw" | "hourly"; + summary?: ObserverActivitySummary; range: string; interval: string; radio: ActivityRadio | null; @@ -154,10 +166,11 @@ export interface ObserverActivity { // Sub-tab + time-range identifiers shared across the Stats page. export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; -export type StatsRange = "24h" | "7d" | "30d"; +export type StatsRange = "24h" | "3d" | "7d" | "30d"; export const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, + "3d": 3 * 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 217f4c33..383ee1ac 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,10 +1,11 @@ -import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { getObserver, getObserverTelemetry, getObserverActivity, isNotFound } from "../../api/client"; import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. const RANGE_PARAM: Record = { "24h": "24h", + "3d": "72h", "7d": "168h", "30d": "720h", }; @@ -13,16 +14,18 @@ const RANGE_PARAM: Record = { // charts don't drown in points. const INTERVAL_PARAM: Record = { "24h": "1h", + "3d": "1h", "7d": "6h", "30d": "24h", }; -export function useObserver(observerId: string | null) { +export function useObserver(observerId: string | null, poll = false) { return useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId!), enabled: !!observerId, staleTime: 30_000, + refetchInterval: poll ? 60_000 : false, refetchOnWindowFocus: false, }); } @@ -30,6 +33,7 @@ export function useObserver(observerId: string | null) { // Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points. const ACTIVITY_INTERVAL: Record = { "24h": "15m", + "3d": "1h", "7d": "1h", "30d": "6h", }; @@ -43,16 +47,15 @@ export function activityRefetchInterval(error: unknown): number | false { return isNotFound(error) ? false : 60_000; } -export function useObserverActivity(observerId: string | null, range: StatsRange) { +export function useObserverActivity(observerId: string | null, range: StatsRange, until?: number) { const params = activityParamsFor(range); return useQuery({ - queryKey: ["observer-activity", observerId, range], - queryFn: () => getObserverActivity(observerId!, params.range, params.interval), + queryKey: ["observer-activity", observerId, range, until], + queryFn: () => getObserverActivity(observerId!, params.range, params.interval, until), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, - refetchInterval: (query) => activityRefetchInterval(query.state.error), + refetchInterval: (query) => until == null ? activityRefetchInterval(query.state.error) : false, }); } @@ -63,7 +66,6 @@ export function useObserverTelemetry(observerId: string | null, range: StatsRang queryFn: () => getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); } diff --git a/src/hooks/useTick.ts b/src/hooks/useTick.ts index 803552df..4fd666f5 100644 --- a/src/hooks/useTick.ts +++ b/src/hooks/useTick.ts @@ -1,11 +1,11 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; // Forces a re-render on a fixed interval so relative time labels ("2m ago") stay fresh. Backed by one // shared interval per interval-length (module-level), so the many instances across the app // subscribe to a single timer instead of each spinning up its own setInterval. interface Ticker { - version: number; + time: number; listeners: Set<() => void>; id: ReturnType | null; } @@ -15,7 +15,7 @@ const tickers = new Map(); function getTicker(intervalMs: number): Ticker { let t = tickers.get(intervalMs); if (!t) { - t = { version: 0, listeners: new Set(), id: null }; + t = { time: Date.now(), listeners: new Set(), id: null }; tickers.set(intervalMs, t); } return t; @@ -25,8 +25,9 @@ function subscribe(intervalMs: number, listener: () => void): () => void { const t = getTicker(intervalMs); t.listeners.add(listener); if (t.id === null) { + t.time = Date.now(); t.id = setInterval(() => { - t!.version++; + t!.time = Date.now(); t!.listeners.forEach((l) => l()); }, intervalMs); } @@ -39,10 +40,11 @@ function subscribe(intervalMs: number, listener: () => void): () => void { }; } -export function useTick(intervalMs = 10_000): void { - useSyncExternalStore( - (listener) => subscribe(intervalMs, listener), - () => getTicker(intervalMs).version, +export function useTick(intervalMs = 10_000): number { + const listen = useCallback((listener: () => void) => subscribe(intervalMs, listener), [intervalMs]); + return useSyncExternalStore( + listen, + () => getTicker(intervalMs).time, () => 0, ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 396320e1..999ec3f3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -66,8 +66,10 @@ "ranges": { "24h": "24h", "7d": "7d", - "30d": "30d" - } + "30d": "30d", + "3d": "3d" + }, + "archiveHelp": "Hourly analytics keep summaries for up to 30 days. Packet details and overlap use retained raw records; older summary history accumulates after archiving is enabled." }, "signal": { "title": "RF / Signal", @@ -223,6 +225,195 @@ }, "timestamp": { "ago": "{{duration}} ago" + }, + "observerPage": { + "back": "Back to observers", + "range": "Time range", + "open": "Open observer dashboard", + "legacy": "Legacy presence count", + "metrics": "Observer metrics", + "records": "Recorded packets", + "lastHour": "Last complete hour", + "lastPacket": "Last recorded packet", + "battery": "Battery", + "uptime": "Device uptime", + "noise": "Noise floor", + "packets": "Packets", + "search": "Find an observer", + "choose": "Choose an observer", + "searchHelp": "Search by name to find observers outside the first 50 results.", + "summaryMissing": "Packet summary unavailable on this server. Charts remain available.", + "trafficRecent": "Recent packet traffic", + "trafficQuiet": "No recent recorded packets", + "statusRecent": "Recent status", + "statusStale": "Status is stale", + "statusMissing": "No status report", + "scopeNote": "History for this observer across all received regions.", + "activity": "Recorded packets per {{interval}}", + "mix": "Packet-type mix", + "signal": "Received signal", + "airtime": "Airtime TX / RX", + "device": "Device telemetry", + "details": "Device details", + "queue": "Queue length", + "errors": "Receive errors", + "busy": "Channel busy", + "empty": "No packets recorded in this period", + "noTelemetry": "No telemetry reported", + "noTelemetryHelp": "This observer reports no device telemetry for this period.", + "loadFailed": "Observer could not be loaded.", + "retry": "Retry", + "model": "Model", + "firmware": "Firmware", + "client": "Client", + "radio": "Radio", + "publicKey": "Public key", + "copy": "Copy", + "copied": "Copied", + "broker": "Broker", + "presence": "Last contact", + "packetArrival": "Packet arrival", + "firstSeen": "First seen", + "copyLink": "Copy observer link", + "updated": "Calculated", + "window": "UTC window", + "windowHelp": "The latest bucket may be partial; gaps are missing records, not proof of an outage. Repeated deliveries of the same retained packet/observer pair count once.", + "exact": "Exact activity values", + "time": "Time (UTC)", + "mean": "Average", + "minimum": "Minimum", + "unknown": "Unknown", + "latestStatus": "Latest status", + "latestTelemetry": "Latest telemetry interval", + "recording": "Loading observer…", + "hour": "hour", + "day": "day", + "trafficUnknown": "Packet freshness unavailable", + "returnTo": "Back to {{page}}", + "directory": "All observers", + "sidebarSearch": "Search observers" + }, + "observerCompare": { + "open": "Compare with…", + "close": "Close comparison", + "title": "Compare observers", + "partner": "Observer B", + "choose": "Choose an observer to compare", + "invalidObserver": "Choose a different observer with a valid identifier.", + "invalidTime": "This comparison time is invalid or too old. Refresh to use the latest complete window.", + "refresh": "Refresh both observers", + "unavailable": "Aligned summaries are unavailable. Refresh both observers, or update the server to support shared windows.", + "currentNote": "Activity totals use this shared window. Device values and the latest complete hour describe current conditions; the hour start is shown in UTC.", + "metrics": "Observer comparison metrics", + "metric": "Metric", + "activity": "Recorded packets · aligned activity", + "overlap": "Retained flood-packet overlap", + "retention": "Raw packet history may expire before the longer-lived activity summaries. Overlap counts only retained flood packets in the shared window, across all regions.", + "total_one": "{{count}} distinct flood packet", + "total_other": "{{count}} distinct flood packets", + "empty": "No retained flood packets were reported by either observer in this window.", + "onlyA": "Only A", + "both": "Both", + "onlyB": "Only B", + "definition": "Each flood packet hash counts once across both observers. Repeat receptions are deduplicated. This is not a packet-loss measurement: expiry, offline devices and broker interruptions affect the available reports.", + "windowLimit": "Choose a period of 3 days or less for retained packet records.", + "retainedWindow": "Packet details and overlap use retained raw records, with a maximum 3-day window." + }, + "investigation": { + "title": "Reception evidence", + "reports_one": "{{count}} retained report", + "reports_other": "{{count}} retained reports", + "observers_one": "{{count}} reporting observer", + "observers_other": "{{count}} reporting observers", + "pathNote": "Grouped by path prefixes and hash width, not confirmed physical routes. Inspect each report for candidate identities.", + "traceNote": "TRACE entries describe the intended route, not a measured reception path. Inspect the trace readings separately.", + "empty": "No retained reports for this packet.", + "path": "Path {{number}}", + "tracePath": "Intended route {{number}}", + "noEntries": "No path entries", + "unavailablePath": "Path bytes unavailable", + "inspect": "Inspect report", + "observer": "Inspect observer", + "map": "Map report", + "unmappable": "Mapping needs at least two uniquely resolved, valid locations.", + "retention": "Reports include all received regions and follow packet/observer deduplication, so not every reception or path is retained. Expired reports are unavailable; reported timing does not establish packet loss or delivery.", + "unavailableReport": "Selected report is unavailable or its identifier is invalid. Choose a retained report below.", + "copy": "Copy link", + "copyPacket": "Copy packet link", + "viewAll": "View path on map", + "mapDialog": "Packet path map", + "mapTitle": "Packet Path", + "copyPath": "Copy path link", + "closeMap": "Close path map", + "allPaths": "All paths", + "traceRoute": "Trace route", + "mapNote": "Only uniquely resolved locations are shown. Unresolved or unlocated gaps are not connected; lines do not establish direct radio links or delivery.", + "unavailableMap": "The selected path cannot be mapped from the retained evidence. Choose an available path or All paths.", + "reportedBy_one": "Reported by {{count}} observer", + "reportedBy_other": "Reported by {{count}} observers", + "first": "First", + "last": "Last", + "span": "Reported span", + "showAll": "Show all {{count}} path groups", + "fewer": "Show fewer paths", + "nodeDialog": "Node detail", + "packetDialog": "Packet analyzer", + "closeObserver": "Close observer details", + "observerList": "Inspect an observer" + }, + "routeEvidence": { + "title": "Retained reports", + "window": "Time window", + "day": "24 hours", + "week": "7 days", + "month": "30 days", + "shared": "Shared time window", + "invalid": "Invalid shared time window. Choose a range to continue.", + "loading": "Loading retained reports…", + "missing": "The saved route or evidence API is unavailable on this server.", + "error": "Could not load retained reports. Try again.", + "retry": "Retry", + "savedPath": "Full saved route", + "match": "Matches the complete saved prefix sequence at {{width}} byte(s) per hop. Other widths, subsegments, TRACE and unclassified reports are excluded.", + "caution": "Matching bytes are not proof of the same physical route. Hop names are the saved route identities; inspect each packet for its candidate matches.", + "retention": "These are retained reports, not distinct packets or the route’s lifetime count. Reports can expire while the saved route remains. The time window ends at the exclusive UTC boundary shown above.", + "reports_one": "{{count}} report loaded", + "reports_other": "{{count}} reports loaded", + "unavailable": "The saved prefixes cannot be used to match retained reports.", + "empty": "No retained reports match this saved prefix sequence in this window. History may have expired or used a different prefix width.", + "more": "Load more reports", + "cap": "Showing up to 500 reports. Choose a shorter window to narrow the investigation.", + "legacy": "Retained report links need a server that supplies stable route keys.", + "width": "Saved prefixes · {{width}} byte(s) per hop", + "brief": "Prefix matches, not confirmed physical routes. Raw reports may expire.", + "definition": "How these reports are matched and counted", + "close": "Close route reports" + }, + "channelMessages": { + "select": "Select a channel", + "back": "Back to channels", + "hash": "hash: {{hash}}", + "keyKnown": "key known", + "noKey": "no key", + "hashtag": "hashtag", + "keyMissing": "No decryption key is configured for this channel.", + "scope": "Transport scope", + "allScopes": "All scopes", + "aboutScopes": "About scope labels", + "scopeHelp": "Labels describe the first recorded packet, not the channel key. Other receptions may differ; inspect the packet for its reports. Only retained messages are shown.", + "unscoped": "No transport scope", + "unknownScope": "Unresolved scope", + "unavailableScope": "Scope unavailable", + "inspect": "Inspect packet from {{sender}}", + "packet": "Packet", + "loading": "Loading messages…", + "loadingOlder": "Loading older messages… ({{count}} shown)", + "loadOlder": "Load older messages", + "empty": "No retained messages for this channel.", + "emptyScope": "No retained messages match {{scope}}.", + "emptyNoKey": "No decrypted messages are available without a known channel key.", + "error": "Could not load messages.", + "retry": "Retry" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c6594263..ec00f6da 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -67,8 +67,10 @@ "ranges": { "24h": "24 h", "7d": "7 j", - "30d": "30 j" - } + "30d": "30 j", + "3d": "3 j" + }, + "archiveHelp": "Les statistiques horaires conservent des résumés jusqu’à 30 jours. Les détails et les recoupements utilisent les paquets bruts conservés ; l’historique s’accumule après l’activation de l’archivage." }, "signal": { "title": "RF / Signal", @@ -227,6 +229,195 @@ }, "timestamp": { "ago": "il y a {{duration}}" + }, + "observerPage": { + "back": "Retour aux observateurs", + "range": "Période", + "open": "Ouvrir le tableau de bord", + "legacy": "Ancien compteur de présence", + "metrics": "Mesures de l’observateur", + "records": "Paquets enregistrés", + "lastHour": "Dernière heure complète", + "lastPacket": "Dernier paquet enregistré", + "battery": "Batterie", + "uptime": "Durée de fonctionnement", + "noise": "Bruit de fond", + "packets": "Paquets", + "search": "Rechercher un observateur", + "choose": "Choisir un observateur", + "searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.", + "summaryMissing": "Résumé des paquets indisponible sur ce serveur. Les graphiques restent disponibles.", + "trafficRecent": "Trafic de paquets récent", + "trafficQuiet": "Aucun paquet récent enregistré", + "statusRecent": "État récent", + "statusStale": "État périmé", + "statusMissing": "Aucun rapport d’état", + "scopeNote": "Historique de cet observateur, toutes régions de réception confondues.", + "activity": "Paquets enregistrés par {{interval}}", + "mix": "Répartition des types", + "signal": "Signal reçu", + "airtime": "Temps radio TX / RX", + "device": "Télémétrie de l’appareil", + "details": "Détails de l’appareil", + "queue": "File d’attente", + "errors": "Erreurs de réception", + "busy": "Occupation du canal", + "empty": "Aucun paquet enregistré sur cette période", + "noTelemetry": "Aucune télémétrie reçue", + "noTelemetryHelp": "Cet observateur ne rapporte aucune télémétrie pour cette période.", + "loadFailed": "Impossible de charger l’observateur.", + "retry": "Réessayer", + "model": "Modèle", + "firmware": "Micrologiciel", + "client": "Client", + "radio": "Radio", + "publicKey": "Clé publique", + "copy": "Copier", + "copied": "Copié", + "broker": "Courtier", + "presence": "Dernier contact", + "packetArrival": "Réception de paquet", + "firstSeen": "Première réception", + "copyLink": "Copier le lien de l’observateur", + "updated": "Calculé", + "window": "Période UTC", + "windowHelp": "La dernière période peut être partielle ; les lacunes sont des données manquantes, pas la preuve d’une panne. Les livraisons répétées du même couple paquet/observateur conservé ne comptent qu’une fois.", + "exact": "Valeurs exactes de l’activité", + "time": "Heure (UTC)", + "mean": "Moyenne", + "minimum": "Minimum", + "unknown": "Inconnu", + "latestStatus": "Dernier état", + "latestTelemetry": "Dernière période de télémétrie", + "recording": "Chargement de l’observateur…", + "hour": "heure", + "day": "jour", + "trafficUnknown": "Fraîcheur des paquets inconnue", + "returnTo": "Retour à « {{page}} »", + "directory": "Tous les observateurs", + "sidebarSearch": "Rechercher les observateurs" + }, + "observerCompare": { + "open": "Comparer avec…", + "close": "Fermer la comparaison", + "title": "Comparer les observateurs", + "partner": "Observateur B", + "choose": "Choisissez un observateur à comparer", + "invalidObserver": "Choisissez un autre observateur avec un identifiant valide.", + "invalidTime": "La date de comparaison est invalide ou trop ancienne. Actualisez pour utiliser la dernière période complète.", + "refresh": "Actualiser les deux observateurs", + "unavailable": "Les résumés alignés sont indisponibles. Actualisez les deux observateurs ou mettez le serveur à jour pour prendre en charge les périodes communes.", + "currentNote": "Les totaux d’activité utilisent cette période commune. Les valeurs de l’appareil et la dernière heure complète décrivent les conditions actuelles; le début de l’heure est indiqué en UTC.", + "metrics": "Mesures de comparaison des observateurs", + "metric": "Mesure", + "activity": "Paquets enregistrés · activité alignée", + "overlap": "Recoupement des paquets flood conservés", + "retention": "L’historique des paquets bruts peut expirer avant les résumés d’activité. Le recoupement compte uniquement les paquets flood conservés dans la période commune, toutes régions confondues.", + "total_one": "{{count}} paquet flood distinct", + "total_other": "{{count}} paquets flood distincts", + "empty": "Aucun paquet flood conservé n’a été signalé par ces observateurs pendant cette période.", + "onlyA": "A seulement", + "both": "Les deux", + "onlyB": "B seulement", + "definition": "Chaque empreinte de paquet flood est comptée une seule fois pour les deux observateurs. Les réceptions répétées sont dédupliquées. Ce n’est pas une mesure de perte de paquets : l’expiration, les appareils hors ligne et les interruptions des courtiers affectent les rapports disponibles.", + "windowLimit": "Choisissez une période de 3 jours ou moins pour les paquets conservés.", + "retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés, sur une période maximale de 3 jours." + }, + "investigation": { + "title": "Rapports de réception", + "reports_one": "{{count}} rapport conservé", + "reports_other": "{{count}} rapports conservés", + "observers_one": "{{count}} observateur déclarant", + "observers_other": "{{count}} observateurs déclarants", + "pathNote": "Regroupement par préfixes et largeur d’empreinte, pas par trajets physiques confirmés. Examinez les identités candidates de chaque rapport.", + "traceNote": "Les entrées TRACE décrivent l’itinéraire prévu, pas un trajet de réception mesuré. Examinez les mesures de trace séparément.", + "empty": "Aucun rapport conservé pour ce paquet.", + "path": "Trajet {{number}}", + "tracePath": "Itinéraire prévu {{number}}", + "noEntries": "Aucune entrée de trajet", + "unavailablePath": "Octets de trajet indisponibles", + "inspect": "Examiner le rapport", + "observer": "Examiner l’observateur", + "map": "Afficher le rapport sur la carte", + "unmappable": "La carte nécessite au moins deux positions valides résolues sans ambiguïté.", + "retention": "Les rapports incluent toutes les régions reçues et suivent la déduplication paquet/observateur : toutes les réceptions ou tous les trajets ne sont donc pas conservés. Les rapports expirés sont indisponibles; les horaires signalés ne prouvent ni perte ni livraison.", + "unavailableReport": "Le rapport sélectionné est indisponible ou son identifiant est invalide. Choisissez un rapport conservé ci-dessous.", + "copy": "Copier le lien", + "copyPacket": "Copier le lien du paquet", + "viewAll": "Afficher le trajet sur la carte", + "mapDialog": "Carte du trajet du paquet", + "mapTitle": "Trajet du paquet", + "copyPath": "Copier le lien du trajet", + "closeMap": "Fermer la carte du trajet", + "allPaths": "Tous les trajets", + "traceRoute": "Itinéraire de trace", + "mapNote": "Seules les positions résolues sans ambiguïté sont affichées. Les étapes inconnues ou sans position interrompent les lignes, qui ne prouvent ni liaison radio directe ni livraison.", + "unavailableMap": "Le trajet sélectionné ne peut pas être cartographié avec les données conservées. Choisissez un trajet disponible ou Tous les trajets.", + "reportedBy_one": "Signalé par {{count}} observateur", + "reportedBy_other": "Signalé par {{count}} observateurs", + "first": "Premier", + "last": "Dernier", + "span": "Intervalle signalé", + "showAll": "Afficher les {{count}} groupes de trajets", + "fewer": "Afficher moins de trajets", + "nodeDialog": "Détails du nœud", + "packetDialog": "Analyseur de paquets", + "closeObserver": "Fermer les détails de l’observateur", + "observerList": "Examiner un observateur" + }, + "routeEvidence": { + "title": "Rapports conservés", + "window": "Période", + "day": "24 heures", + "week": "7 jours", + "month": "30 jours", + "shared": "Période partagée", + "invalid": "Période partagée invalide. Choisissez une période pour continuer.", + "loading": "Chargement des rapports conservés…", + "missing": "Le trajet enregistré ou l’API des rapports est indisponible sur ce serveur.", + "error": "Impossible de charger les rapports conservés. Réessayez.", + "retry": "Réessayer", + "savedPath": "Trajet complet enregistré", + "match": "Correspond à la séquence complète des préfixes enregistrés, avec {{width}} octet(s) par saut. Les autres tailles, les sous-trajets, TRACE et les rapports non classés sont exclus.", + "caution": "Des octets identiques ne prouvent pas un même trajet physique. Les noms des sauts correspondent aux identités enregistrées ; consultez chaque paquet pour voir ses correspondances possibles.", + "retention": "Il s’agit de rapports conservés, et non de paquets distincts ni du total historique du trajet. Les rapports peuvent expirer alors que le trajet reste enregistré. La fin de période UTC affichée ci-dessus est exclusive.", + "reports_one": "{{count}} rapport chargé", + "reports_other": "{{count}} rapports chargés", + "unavailable": "Les préfixes enregistrés ne permettent pas de rechercher les rapports conservés.", + "empty": "Aucun rapport conservé ne correspond à ces préfixes dans cette période. L’historique a pu expirer ou utiliser une autre taille de préfixe.", + "more": "Charger plus de rapports", + "cap": "Affichage limité à 500 rapports. Choisissez une période plus courte pour affiner la recherche.", + "legacy": "Les liens vers les rapports conservés nécessitent un serveur fournissant des clés de trajet stables.", + "width": "Préfixes enregistrés · {{width}} octet(s) par saut", + "brief": "Préfixes correspondants, pas des trajets physiques confirmés. Les rapports bruts peuvent expirer.", + "definition": "Correspondance et comptage des rapports", + "close": "Fermer les rapports du trajet" + }, + "channelMessages": { + "select": "Choisir un canal", + "back": "Retour aux canaux", + "hash": "empreinte : {{hash}}", + "keyKnown": "clé connue", + "noKey": "sans clé", + "hashtag": "mot-dièse", + "keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.", + "scope": "Portée de transport", + "allScopes": "Toutes les portées", + "aboutScopes": "À propos des portées", + "scopeHelp": "Les étiquettes décrivent le premier paquet enregistré, pas la clé du canal. D’autres réceptions peuvent différer ; inspectez le paquet pour consulter ses observations. Seuls les messages conservés sont affichés.", + "unscoped": "Sans portée de transport", + "unknownScope": "Portée non résolue", + "unavailableScope": "Portée indisponible", + "inspect": "Inspecter le paquet de {{sender}}", + "packet": "Paquet", + "loading": "Chargement des messages…", + "loadingOlder": "Chargement des messages plus anciens… ({{count}} affichés)", + "loadOlder": "Charger les messages plus anciens", + "empty": "Aucun message conservé pour ce canal.", + "emptyScope": "Aucun message conservé ne correspond à {{scope}}.", + "emptyNoKey": "Aucun message déchiffré n’est disponible sans clé de canal connue.", + "error": "Impossible de charger les messages.", + "retry": "Réessayer" } } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 97837492..753b3246 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -22,7 +22,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000; export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; +export const TABS = ["Observers", "Packets", "Channels", "Map", "Nodes", "Routes", "Traces", "Analytics"] as const; // Per-deployment .env config (VITE_* prefix is required so the build can expose it to the browser; // the Docker entrypoint sed-substitutes each sentinel at container start — see .build/). diff --git a/src/types/api.ts b/src/types/api.ts index e8fbf134..5de0906a 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -157,6 +157,7 @@ export interface RouteHop { export interface KnownRoute { id: number; + pathKey?: string; // stable full-route identity; absent on older servers iata: string; hopCount: number; hops: RouteHop[]; @@ -165,6 +166,32 @@ export interface KnownRoute { observationCount: number; } +export interface RouteObservation { + id: number; + packetHash: string; + observerId: string; + observerName?: string; + heardAt: number; + payloadType: number; + payloadTypeName: string; + rssi?: number; + snr?: number; +} + +export interface RouteEvidence { + route: KnownRoute; + windowStart: number; + windowEnd: number; + generatedAt: number; + matchType: "saved_path_prefixes"; + matchAvailable: boolean; + hashSize?: number; + pathBytes?: string; + items: RouteObservation[]; + hasMore: boolean; + nextPageCursor?: string; +} + // the boundary hop in a cross-IATA route: the link from the last node in the source IATA to the // first node in the target IATA. export interface CrossIATAHop { diff --git a/tests/App.analyzerObservationCarryOver.test.tsx b/tests/App.analyzerObservationCarryOver.test.tsx index 7c407245..882fd3e1 100644 --- a/tests/App.analyzerObservationCarryOver.test.tsx +++ b/tests/App.analyzerObservationCarryOver.test.tsx @@ -136,3 +136,12 @@ describe("opening the analyzer from an expanded row", () => { expect(within(drawer).getByText("Observer Three")).toBeInTheDocument(); }); }); + +it("replaces a shared observation when another report is selected in the row", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=AA11&observation=3"); + render(); + fireEvent.click(await screen.findByText("Observer Two")); + const drawer = await screen.findByTestId("packet-analyzer-drawer"); + expect(within(drawer).getByText("Observer Two")).toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("2"); +}); diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx new file mode 100644 index 00000000..ac0191db --- /dev/null +++ b/tests/App.observerInvestigation.test.tsx @@ -0,0 +1,135 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useState, type ReactNode } from "react"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { useSearchParams } from "react-router-dom"; +import { App } from "../src/App"; +import { useRegionSelection } from "../src/hooks/useRegion"; +import type { PacketDetail } from "../src/types/api"; +import i18n from "../src/i18n"; + +vi.mock("../src/api/ws-manager", () => ({ WsManager: class { + connect() {} disconnect() {} updateSubscription() {} +} })); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => { + const { setSelection } = useRegionSelection(); + return <>{children}; +} })); +vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObserver, onAnalyzePacket, onViewNode }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string, id: number) => void; onViewNode: (id: string) => void }) => { + const [filter, setFilter] = useState(""); const [params] = useSearchParams(); + return
setFilter(e.target.value)} />{params.toString()}
Routes
; +} })); +vi.mock("../src/features/observers/ObserverTable", () => ({ ObserverTable: () =>

Observer directory

})); +vi.mock("../src/features/stats/MeshTab", () => ({ MeshTab: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => { + const [value, setValue] = useState(""); + return <> setValue(e.target.value)} />; +} })); +vi.mock("../src/features/stats/ObserverTab", () => ({ ObserverTab: ({ selectedObserverId, range }: { selectedObserverId: string; range: string }) =>

Dashboard {selectedObserverId} {range}

})); +// Exercise the real analytics shell without loading charts unrelated to its observer action. +vi.mock("../src/features/stats/TrafficTab", () => ({ TrafficTab: () => null })); +vi.mock("../src/features/stats/SignalTab", () => ({ SignalTab: () => null })); +vi.mock("../src/features/stats/PathsTab", () => ({ PathsTab: () => null })); +vi.mock("../src/features/stats/ScopesTab", () => ({ ScopesTab: () => null })); +vi.mock("../src/features/stats/TalkersTab", () => ({ TalkersTab: () => null })); +vi.mock("../src/features/stats/ClockDriftTab", () => ({ ClockDriftTab: () => null })); +vi.mock("../src/features/stats/CompareObserversTab", () => ({ CompareObserversTab: () => null })); +vi.mock("../src/features/stats/NeighbourGraphTab", () => ({ NeighbourGraphTab: () => null })); +vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ observerId, onClose, onViewStats, onAnalyzePacket }: { observerId: string; onClose: () => void; onViewStats: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <>

Observer {observerId}

})); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ nodeId, onClose, onViewObserver }: { nodeId: string; onClose: () => void; onViewObserver: (id: string) => void }) => <>

Node {nodeId}

})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 1 } } as unknown as PacketDetail : undefined, isLoading: false }) })); +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onClose, onViewObserver, onViewNode, onViewPath, selectedObservationId, onSelectObservation }: { detail: PacketDetail; onClose: () => void; onViewObserver: (id: string) => void; onViewNode: (id: string) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void }) => <>

Packet {detail?.packetHash} report {selectedObservationId}

})); +vi.mock("../src/features/map/PacketPathMap", () => ({ PacketPathMap: () =>
Path map
})); + +beforeEach(() => { + window.history.replaceState({}, "", "/?tab=Routes&route=full-route&routeIata=YOW"); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); +const click = (name: string) => { const button = screen.getByRole("button", { name, exact: true }); button.focus(); fireEvent.click(button); }; +const escape = () => fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); +const browserBack = async () => { await act(async () => { window.history.back(); }); await waitFor(() => expect(window.location.search).toContain("tab=Routes")); }; + +describe("observer investigation return", () => { + it("dismisses only the observer with Escape and returns keyboard focus", () => { + render(); click("Route observer"); escape(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Route observer" })).toHaveFocus(); + }); + it("orders route packet -> observer -> advert packet and preserves the earlier report", () => { + render(); click("Route packet"); click("Select report 8"); click("Packet observer"); click("Advert packet"); + expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet bb"); + escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1"); + expect(screen.getByRole("button", { name: "Advert packet" })).toHaveFocus(); + escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa report 8"); + escape(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("keeps the node and packet underneath the observer when Escape is pressed", () => { + render(); click("Route packet"); click("Packet node"); click("Node observer"); + escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Node n1"); + escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); + click("Packet map"); escape(); + expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); + }); + it("returns to an already-open observer instead of making a cyclic panel chain", () => { + render(); click("Route packet"); click("Select report 8"); click("Packet observer"); click("Advert packet"); click("Packet observer"); + expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1"); + escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa report 8"); + }); + it("returns from a changed dashboard window to the same route, filter, scroll and observer", async () => { + render(); + fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "roof" } }); + screen.getByTestId("route-scroll").scrollTop = 180; + click("Route observer"); click("Open dashboard"); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 3d"); + expect(window.location.search).toContain("tab=Observers&observer=o1"); + expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route"); + fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "24h" } }); + click("Compare with…"); + expect(window.location.search).toContain("compareWith="); + click("Back to Routes"); + await waitFor(() => expect(window.location.search).toContain("tab=Routes")); + expect(screen.getByLabelText("Route filter")).toHaveValue("roof"); + expect(screen.getByTestId("route-scroll").scrollTop).toBe(180); + await waitFor(() => expect(screen.getByRole("button", { name: "Open dashboard" })).toHaveFocus()); + escape(); expect(screen.getByRole("button", { name: "Route observer" })).toHaveFocus(); + }); + it("supports browser Back/Forward without rebuilding the originating screen", async () => { + render(); fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "keep" } }); + click("Route observer"); click("Open dashboard"); + await screen.findByRole("heading", { level: 1 }); + await browserBack(); expect(screen.getByLabelText("Route filter")).toHaveValue("keep"); + await act(async () => { window.history.forward(); }); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1"); + await browserBack(); expect(screen.getByLabelText("Route filter")).toHaveValue("keep"); + }); + it("uses the same return flow for the Analytics observer leaderboard", async () => { + await import("../src/features/stats/StatsOverview"); + window.history.replaceState({}, "", "/?tab=Analytics&statsTab=mesh&range=24h"); + render(); + fireEvent.change(await screen.findByLabelText("Analytics local state"), { target: { value: "keep analytics" } }); + click("Leaderboard observer"); + expect(await screen.findByRole("button", { name: "Back to Analytics" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 24h"); + click("Back to Analytics"); + await waitFor(() => expect(window.location.search).toContain("statsTab=mesh")); + expect(screen.getByLabelText("Analytics local state")).toHaveValue("keep analytics"); + }); + it("does not invent a return destination for a reloaded/shared dashboard", async () => { + window.history.replaceState({ usr: { beaconObserverReturnKey: "old" } }, "", "/?tab=Observers&observer=o2&range=24h"); + render(); expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o2 24h"); + expect(screen.queryByRole("button", { name: "Back to Routes" })).not.toBeInTheDocument(); + click("← Back to observers"); expect(screen.getByText("Observer directory")).toBeInTheDocument(); + }); + it("ends the retained investigation when the operator changes tabs or regions", async () => { + render(); click("Route observer"); click("Open dashboard"); await screen.findByRole("heading", { level: 1 }); + click("Change region"); + await waitFor(() => expect(screen.queryByRole("button", { name: "Back to Routes" })).not.toBeInTheDocument()); + expect(screen.queryByTestId("route-origin")).not.toBeInTheDocument(); + click("Route tab"); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + it("translates the return action", async () => { + await i18n.changeLanguage("fr"); render(); click("Route observer"); click("Open dashboard"); + expect(await screen.findByRole("button", { name: /Retour à « Routes »/ })).toBeInTheDocument(); + }); +}); diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index 749524dd..aedbb019 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent, act, within } from "@testing-library/react"; +import { render, screen, fireEvent, act, within, waitFor } from "@testing-library/react"; import { App } from "../src/App"; import type { PacketSummary, PacketDetail } from "../src/types/api"; @@ -33,6 +33,11 @@ vi.mock("../src/api/client", () => ({ getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), })); +vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ + ObserverDetailPanel: ({ observerId, onViewStats }: { observerId: string; onViewStats: (id: string) => void }) => , +})); +vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () =>

Observer dashboard

})); + const packet: PacketSummary = { packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT", routeType: 1, routeTypeName: "FLOOD", @@ -136,6 +141,24 @@ afterEach(() => { }); describe("Packets deep links", () => { + it("returns from an observer dashboard to the same packet, report and filters", async () => { + setMobile(false); + window.history.pushState({}, "", "/?tab=Packets&hash=AA11&analyze=1&observation=1&iata=YOW&q=missing"); + render(); + const section = await screen.findByRole("region", { name: "Reception evidence" }); + expect(within(section).getByText("No path entries").closest("details")).toHaveAttribute("open"); + fireEvent.click(within(section).getByRole("button", { name: "Inspect observer" })); + fireEvent.click(await screen.findByRole("button", { name: "Open observer dashboard" })); + expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).has("observation")).toBe(false); + act(() => window.history.back()); + // The originating drawer now stays mounted while hidden; wait for history, not its existence. + await waitFor(() => expect(new URLSearchParams(window.location.search).get("hash")).toBe("AA11")); + expect(await screen.findByTestId("packet-analyzer-drawer")).toBeInTheDocument(); + const params = new URLSearchParams(window.location.search); + expect(params.get("hash")).toBe("AA11"); expect(params.get("observation")).toBe("1"); + expect(params.get("iata")).toBe("YOW"); expect(params.get("q")).toBe("missing"); + }); it.each([false, true])("opens an unloaded selection explicitly and preserves region and filters (mobile=%s)", async (mobile) => { setMobile(mobile); window.history.pushState({}, "", "/?tab=Packets&hash=BB22&iata=YOW&q=missing"); diff --git a/tests/App.pathLinkRestore.test.tsx b/tests/App.pathLinkRestore.test.tsx index 787a4fea..d00dd38b 100644 --- a/tests/App.pathLinkRestore.test.tsx +++ b/tests/App.pathLinkRestore.test.tsx @@ -20,6 +20,13 @@ beforeEach(() => getPacketDetail.mockReset()); const detail = { packetHash: "AA11", observations: [] } as unknown as PacketDetail; describe("PathLinkRestore", () => { + it("never restores a different packet while the shared packet is still loading", async () => { + getPacketDetail.mockResolvedValue(detail); + const onRestore = vi.fn(); + render(, { wrapper }); + await waitFor(() => expect(onRestore).toHaveBeenCalledWith(detail, "obs-alpha")); + expect(onRestore).toHaveBeenCalledTimes(1); + }); // Regression: PacketPathMapModal's Copy Link strips ?analyze, so a copied path link carries ?hash // without it — the popup can't rely on the analyzer drawer's fetch and needs its own. it("restores the path popup from ?hash&?path alone, with no ?analyze", async () => { diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx new file mode 100644 index 00000000..80b81420 --- /dev/null +++ b/tests/App.quickObserver.test.tsx @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; +import { ModalOverlay } from "../src/components/ModalOverlay"; + +vi.mock("../src/api/ws-manager", () => ({ WsManager: class { connect() {} disconnect() {} updateSubscription() {} } })); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { children: ReactNode }) => <>{children} })); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ onViewObserver, onAnalyzePacket }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <> })); +vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string) => void }) => })); +vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }) })); +vi.mock("../src/features/packets/PacketAnalyzerOverlay", () => ({ PacketAnalyzerOverlay: ({ detail, inactive, onClose, onViewObserver }: { detail?: { packetHash: string }; inactive?: boolean; onClose: () => void; onViewObserver: (id: string) => void }) =>

{detail?.packetHash}

})); + +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onViewObserver }: { detail?: { packetHash: string }; onViewObserver: (id: string) => void }) => <>

{detail?.packetHash}

})); + +beforeEach(() => { window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); }); + +describe("quick observer investigation", () => { + it("closes the quick observer before opening its advert analyzer", () => { + render(); + fireEvent.click(screen.getByText("Node observer")); + fireEvent.click(screen.getByText("Observer advert")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getByRole("heading")).toHaveTextContent("advert"); + expect(screen.queryByRole("button", { name: "Observer advert" })).not.toBeInTheDocument(); + }); + it("marks the underlying packet inactive while its observer is open", () => { + render(); + fireEvent.click(screen.getByText("Node packet")); + fireEvent.click(screen.getByText("Packet observer")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getAllByRole("dialog", { hidden: true }).filter(node => node.getAttribute("aria-modal") === "false")).toHaveLength(1); + }); +}); diff --git a/tests/api/observer-comparison.test.ts b/tests/api/observer-comparison.test.ts index ed346029..539c7eb0 100644 --- a/tests/api/observer-comparison.test.ts +++ b/tests/api/observer-comparison.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getObserverComparison } from "../../src/api/client"; +import { getObserverComparison, getObserverActivity, getObserver } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); @@ -15,3 +15,21 @@ it("sends the complete comparison scope, including an epoch-zero start, and cons controller.abort(); expect(options.signal.aborted).toBe(true); }); + +it("sends the shared activity anchor without altering the selected range", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ points: [] }) }); + vi.stubGlobal("fetch", fetcher); + await getObserverActivity("a", "168h", "1h", 123456789); + expect(Object.fromEntries(new URL(fetcher.mock.calls[0][0]).searchParams)).toEqual({ range: "168h", interval: "1h", until: "123456789" }); +}); + +it.each([ { stats: { noise_floor: -117 } }, btoa('{"stats":{"noise_floor":-117}}') ])("reads both object and legacy byte-encoded status metadata", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + expect((await getObserver("a")).statusMetadata).toEqual({ stats: { noise_floor: -117 } }); +}); + +it.each(["not base64!", btoa('null'), btoa('[]')])("ignores malformed metadata without losing the observer", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + const observer = await getObserver("a"); + expect(observer.id).toBe("a"); expect(observer.statusMetadata).toBeUndefined(); +}); diff --git a/tests/api/route-evidence.test.ts b/tests/api/route-evidence.test.ts new file mode 100644 index 00000000..10de5813 --- /dev/null +++ b/tests/api/route-evidence.test.ts @@ -0,0 +1,16 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { getRouteEvidence } from "../../src/api/client"; + +afterEach(() => vi.unstubAllGlobals()); +it("encodes route identity and preserves the opaque cursor without numeric truncation", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ items: [], hasMore: false }) }); + vi.stubGlobal("fetch", fetcher); + const cursor = "v1:YOW:abcdef:1:2:1234567890000001:9007199254740993"; + const signal = new AbortController().signal; + await getRouteEvidence("Y/W", "abc/def", { pageCursor: cursor, limit: 50 }, signal); + const url = new URL(fetcher.mock.calls[0][0]); + expect(url.pathname).toContain("/routes/Y%2FW/abc%2Fdef/observations"); + expect(url.searchParams.get("pageCursor")).toBe(cursor); + expect(url.searchParams.has("range")).toBe(false); + expect(fetcher.mock.calls[0][1].signal).toBe(signal); +}); diff --git a/tests/components/CopyLinkButton.test.tsx b/tests/components/CopyLinkButton.test.tsx index a7fe9758..5b1b012c 100644 --- a/tests/components/CopyLinkButton.test.tsx +++ b/tests/components/CopyLinkButton.test.tsx @@ -73,3 +73,11 @@ describe("CopyLinkButton", () => { expect(copied.searchParams.get("tab")).toBe("Map"); }); }); + +it("retains only an explicit allowlist when building a focused investigation link", () => { + window.history.replaceState({}, "", "/?regions=ontario&futureFilter=wrong&observer=old&tab=Routes"); + render(); + fireEvent.click(screen.getByRole("button")); + const copied = new URL(writeText.mock.calls[0][0]); + expect(Object.fromEntries(copied.searchParams)).toEqual({ regions: "ontario", tab: "Packets", hash: "abc", path: "all" }); +}); diff --git a/tests/components/ModalOverlay.test.tsx b/tests/components/ModalOverlay.test.tsx index e140cf66..4a0653a9 100644 --- a/tests/components/ModalOverlay.test.tsx +++ b/tests/components/ModalOverlay.test.tsx @@ -12,6 +12,24 @@ function renderOverlay(onClose: () => void) { } describe("ModalOverlay", () => { + it("handles Escape in the active dialog without reaching underlying window handlers", () => { + const close = vi.fn(); const parent = vi.fn(); + window.addEventListener("keydown", parent); + try { + const dialog = renderOverlay(close); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(close).toHaveBeenCalledOnce(); + expect(parent).not.toHaveBeenCalled(); + } finally { window.removeEventListener("keydown", parent); } + }); + it("does not dismiss an inactive layer", () => { + const close = vi.fn(); + render(

Covered content

); + const dialog = screen.getByRole("dialog", { hidden: true }); + fireEvent.keyDown(dialog, { key: "Escape" }); + expect(close).not.toHaveBeenCalled(); + expect(dialog).toHaveAttribute("aria-hidden", "true"); + }); it("closes when a click starts and ends on the backdrop", () => { const onClose = vi.fn(); const backdrop = renderOverlay(onClose); diff --git a/tests/components/SearchBar.compact.test.tsx b/tests/components/SearchBar.compact.test.tsx new file mode 100644 index 00000000..2d56d215 --- /dev/null +++ b/tests/components/SearchBar.compact.test.tsx @@ -0,0 +1,20 @@ +import { describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { SearchBar } from "../../src/components/SearchBar"; + +describe("compact observer search", () => { + it("omits the field menu and coalesces typing before querying", () => { + vi.useFakeTimers(); + try { + const change = vi.fn(); + render( {}} hideField inputLabel="Search observers" />); + const input = screen.getByRole("textbox", { name: "Search observers" }); + fireEvent.change(input, { target: { value: "Ro" } }); + fireEvent.change(input, { target: { value: "Roof" } }); + expect(change).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(300)); + expect(change).toHaveBeenCalledExactlyOnceWith("Roof"); + expect(screen.queryByRole("button", { name: /Name/ })).not.toBeInTheDocument(); + } finally { vi.useRealTimers(); } + }); +}); diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx new file mode 100644 index 00000000..bed16379 --- /dev/null +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -0,0 +1,181 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MessagePanel } from "../../../src/features/channels/MessagePanel"; +import { ChannelList } from "../../../src/features/channels/ChannelList"; +import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { getChannelMessagesPage, getChannels } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ + getChannelMessagesPage: vi.fn(), + getChannels: vi.fn(), + getScopes: vi.fn(async () => ["#can", "#yow"]), +})); +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }) })); +let onMessage: (data: ChannelMessage) => void; +vi.mock("../../../src/hooks/useWsHandlers", () => ({ + useWsChannelMessageHandler: (_m: unknown, fn: typeof onMessage) => { + onMessage = fn; + }, +})); +const channel: ChannelSummary = { + id: 1, + name: "#test", + channelHash: "11", + keyKnown: true, + isHashtag: true, + lastSeen: 1, +}; +const base = { channelHash: "11", senderName: "Fixture", content: "test message", sentAt: 1 }; +const messages = [ + { ...base, id: 4, packetHash: "matched", scope: "#yow", scopeStatus: "matched" as const }, + { ...base, id: 3, packetHash: "unscoped", scope: null, scopeStatus: "unscoped" as const }, + { ...base, id: 2, packetHash: "unknown", scope: null, scopeStatus: "unknown" as const }, + { ...base, id: 1, packetHash: "legacy" }, +]; +const page = (items: ChannelMessage[], nextCursor: number | null = null) => ({ + items, + nextCursor, + hasMore: nextCursor !== null, +}); +function show(node: React.ReactNode) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return { client, ...render({node}) }; +} +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getChannelMessagesPage).mockResolvedValue(page(messages)); + vi.mocked(getChannels).mockResolvedValue({ items: [channel], hasMore: false, nextCursor: null }); + Element.prototype.scrollIntoView = vi.fn(); +}); + +describe("channel scope evidence", () => { + it("distinguishes matched, unscoped, unresolved and legacy metadata, with keyboard packet inspection", async () => { + const inspect = vi.fn(); + show(); + expect(await screen.findByText("#yow")).toBeInTheDocument(); + expect(screen.getByText("No transport scope")).toBeInTheDocument(); + expect(screen.getByText("Unresolved scope")).toBeInTheDocument(); + expect(screen.getByText("Scope unavailable")).toBeInTheDocument(); + fireEvent.click(screen.getAllByRole("button", { name: "Inspect packet from Fixture" })[0]!); + expect(inspect).toHaveBeenCalledTimes(1); + }); + it("switches French labels without refetching or changing protocol names", async () => { + show(); + await screen.findByText("#yow"); + await act(() => i18n.changeLanguage("fr")); + expect(screen.getByText("Sans portée de transport")).toBeInTheDocument(); + expect(screen.getByText("Portée non résolue")).toBeInTheDocument(); + expect(screen.getByText("Portée indisponible")).toBeInTheDocument(); + expect(screen.getByText("#yow")).toBeInTheDocument(); + expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); + }); + it("isolates scope history and live messages, preserving page cursors and channel identity", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, p) => + page(p?.scope === "#yow" ? [messages[0]!] : messages, p?.scope === "#yow" ? 4 : null), + ); + const { client } = show( {}} />); + fireEvent.click(await screen.findByRole("button", { name: /#test/ })); + await screen.findByText("No transport scope"); + fireEvent.click(screen.getByRole("button", { name: /Transport scope.*All scopes/ })); + fireEvent.click(screen.getByRole("option", { name: "#yow", exact: true })); + await waitFor(() => + expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow" })), + ); + await waitFor(() => expect(screen.queryByText("No transport scope")).not.toBeInTheDocument()); + await waitFor(() => expect(client.getQueryData(["channel-messages", 1, "YOW", "#yow"])).toBeDefined()); + const event = { + ...base, + id: 5, + channelId: 1, + packetHash: "live-yow", + content: "in scope", + scope: "#yow", + scopeStatus: "matched" as const, + }; + act(() => { + onMessage({ ...event, packetHash: "other", scope: "#can", content: "wrong scope" }); + onMessage({ ...event, channelId: 2, packetHash: "collision", content: "wrong channel" }); + onMessage(event); + onMessage(event); + }); + expect(await screen.findByText("in scope")).toBeInTheDocument(); + expect(screen.queryByText("wrong scope")).not.toBeInTheDocument(); + expect(screen.queryByText("wrong channel")).not.toBeInTheDocument(); + expect(screen.getAllByText("in scope")).toHaveLength(1); + const cache = client.getQueryData<{ pages: ReturnType[]; pageParams: unknown[] }>([ + "channel-messages", + 1, + "YOW", + "#yow", + ])!; + expect(cache.pages[0]!.nextCursor).toBe(4); + expect(cache.pageParams).toEqual([undefined]); + fireEvent.click(screen.getByRole("button", { name: "Load older messages" })); + await waitFor(() => + expect(getChannelMessagesPage).toHaveBeenLastCalledWith(1, expect.objectContaining({ scope: "#yow", cursor: 4 })), + ); + }); + it("explains an empty filtered history", async () => { + vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); + show( {}} />); + expect(await screen.findByText("No retained messages match #yow.")).toBeInTheDocument(); + }); + it("keeps messages received during history loading without an extra request", async () => { + let resolveHistory!: (value: ReturnType) => void; + const live = { ...messages[0]!, id: 5, channelId: 1, packetHash: "during-read", content: "arrived during history" }; + vi.mocked(getChannelMessagesPage) + .mockImplementationOnce( + () => + new Promise((resolve) => { + resolveHistory = resolve; + }), + ) + .mockResolvedValue(page([live])); + show( {}} />); + fireEvent.click(await screen.findByRole("button", { name: /#test/ })); + await waitFor(() => expect(getChannelMessagesPage).toHaveBeenCalledTimes(1)); + act(() => { + onMessage(live); + onMessage(live); + }); + await act(async () => resolveHistory(page([]))); + expect(await screen.findByText("arrived during history")).toBeInTheDocument(); + expect(getChannelMessagesPage).toHaveBeenCalledTimes(1); + }); + it("explains missing decryption keys without inventing a message scope", async () => { + vi.mocked(getChannelMessagesPage).mockResolvedValue(page([])); + show(); + expect( + await screen.findByText("No decrypted messages are available without a known channel key."), + ).toBeInTheDocument(); + expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); + }); +}); + +it("preserves the newest/live page after more than twenty older pages", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { + const index = params?.cursor ?? 0; + return page( + [ + { + ...base, + id: 1000 - index, + packetHash: `history-${index}`, + content: `History ${index}`, + sentAt: 1000 - index, + }, + ], + index + 1, + ); + }); + show(); + await screen.findByText("History 0"); + for (let index = 1; index <= 21; index++) { + fireEvent.click(screen.getByRole("button", { name: /Load older/ })); + await screen.findByText(`History ${index}`); + } + expect(screen.getByText("History 0")).toBeInTheDocument(); +}); diff --git a/tests/features/channels/MessagePanel.test.tsx b/tests/features/channels/MessagePanel.test.tsx index ca7c44c7..de779cd7 100644 --- a/tests/features/channels/MessagePanel.test.tsx +++ b/tests/features/channels/MessagePanel.test.tsx @@ -4,7 +4,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MessagePanel } from "../../../src/features/channels/MessagePanel"; import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; -// live WS messages have no id — mirror that runtime shape in the page data +// Older live WS messages have no id — preserve that compatible runtime shape. const restMsg: ChannelMessage = { id: 1, packetHash: "ph-rest", @@ -40,6 +40,7 @@ const multiLineMsg: ChannelMessage = { }; vi.mock("../../../src/api/client", () => ({ + getScopes: vi.fn(async () => []), getChannelMessagesPage: vi.fn(() => Promise.resolve({ items: [restMsg, liveMsgA, liveMsgB, multiLineMsg], nextCursor: null, hasMore: false }), ), diff --git a/tests/features/map/PacketPathMapModal.test.tsx b/tests/features/map/PacketPathMapModal.test.tsx index 17595f8a..fe49f0d1 100644 --- a/tests/features/map/PacketPathMapModal.test.tsx +++ b/tests/features/map/PacketPathMapModal.test.tsx @@ -34,18 +34,18 @@ const detail = { } as unknown as PacketDetail; describe("PacketPathMapModal", () => { - it("lists All paths plus a row per observer and starts on All", () => { + it("lists All paths plus a row per observer and starts on All", async () => { render( {}} />); expect(screen.getByText("All paths")).toBeInTheDocument(); expect(screen.getByText("Alpha")).toBeInTheDocument(); expect(screen.getByText("Bravo")).toBeInTheDocument(); - expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("all"); }); - it("isolates a path when its row is clicked", () => { + it("isolates a path when its row is clicked", async () => { render( {}} />); fireEvent.click(screen.getByText("Bravo")); - expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); it("shows each observer's propagation", () => { @@ -61,14 +61,18 @@ describe("PacketPathMapModal", () => { expect(onClose).toHaveBeenCalledOnce(); }); - it("pre-selects the observer from initialSelectedKey", () => { + it("pre-selects the observer from initialSelectedKey", async () => { render( {}} initialSelectedKey="obs-bravo" />); - expect(screen.getByTestId("mini-map")).toHaveTextContent("obs-bravo"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("obs-bravo"); }); - it("falls back to All when initialSelectedKey isn't a known path", () => { + it("does not silently substitute all paths for an unavailable selected path", async () => { render( {}} initialSelectedKey="nope" />); - expect(screen.getByTestId("mini-map")).toHaveTextContent("all"); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("nope"); + expect(screen.getByRole("status")).toHaveTextContent("selected path cannot be mapped"); + fireEvent.click(screen.getByText("All paths")); + expect(await screen.findByTestId("mini-map")).toHaveTextContent("all"); + expect(screen.queryByRole("status")).not.toBeInTheDocument(); }); it("renders a copy-link button", () => { diff --git a/tests/features/map/packet-flow.test.ts b/tests/features/map/packet-flow.test.ts index ae6df1a6..89d8d30c 100644 --- a/tests/features/map/packet-flow.test.ts +++ b/tests/features/map/packet-flow.test.ts @@ -27,18 +27,20 @@ describe("packetChain", () => { }); describe("resolvedPathNodes", () => { - it("returns each hop's first located node as {id,lng,lat}, deduped, in order", () => { + it("suppresses an animation when a gap would invent a link", () => { const path: ResolvedHop[] = [hop("a", -75, 45), { confidence: "none", nodes: [] }, hop("a", -75, 45), hop("b", -76, 46)]; - expect(resolvedPathNodes(path)).toEqual([{ id: "a", lng: -75, lat: 45 }, { id: "b", lng: -76, lat: 46 }]); + expect(resolvedPathNodes(path)).toEqual([]); + expect(resolvedPathNodes([hop("a", -75, 45), hop("a", -75, 45), hop("b", -76, 46)])).toEqual([{ id: "a", lng: -75, lat: 45 }, { id: "b", lng: -76, lat: 46 }]); }); it("skips hops with no located candidate", () => { expect(resolvedPathNodes([{ confidence: "ambiguous", nodes: [{ id: "x", publicKey: "pk" }] }])).toEqual([]); }); - it("skips reset/invalid candidates and still finds a usable candidate", () => { + it("rejects reset/invalid and ambiguous locations while accepting valid zero axes", () => { const candidates: ResolvedHop = { confidence: "ambiguous", nodes: [...hop("reset", 0, 0).nodes, ...hop("invalid", 10, 91).nodes, ...hop("valid", 0, 45).nodes] }; - expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]); + expect(resolvedPathNodes([hop("unknown", 0, 0), candidates, hop("bad", Infinity, 10)])).toEqual([]); + expect(resolvedPathNodes([hop("valid", 0, 45)])).toEqual([{ id: "valid", lng: 0, lat: 45 }]); }); }); diff --git a/tests/features/map/packet-path-evidence.test.ts b/tests/features/map/packet-path-evidence.test.ts new file mode 100644 index 00000000..4f2bf83b --- /dev/null +++ b/tests/features/map/packet-path-evidence.test.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { buildPacketPaths, packetPathsToFeatures } from "../../../src/features/map/packet-path"; +import { resolvedPathNodes } from "../../../src/features/map/packet-flow"; +import type { PacketDetail, ResolvedHop } from "../../../src/types/api"; + +const hop = (id: string): ResolvedHop => ({ confidence: "high", nodes: [{ id, publicKey: id, latitude: 45, longitude: -75 + Number(id) }] }); +function detail(hops: ResolvedHop[]): PacketDetail { + return { header: { payloadType: 2 }, observations: [{ id: 1, observerId: "observer", resolvedPath: hops }] } as unknown as PacketDetail; +} +it("does not choose the located candidate in an ambiguous relay", () => { + const ambiguous: ResolvedHop = { confidence: "ambiguous", nodes: [{ id: "unknown", publicKey: "a" }, ...hop("1").nodes] }; + const paths = buildPacketPaths(detail([hop("0"), ambiguous, hop("2"), hop("3")])); + expect(paths[0].points.map(p => p.id)).toEqual(["0", "2", "3"]); + expect(packetPathsToFeatures(paths, null).lines.features.map(f => f.geometry.coordinates)).toEqual([[[-73,45],[-72,45]]]); +}); +it("keeps isolated known locations without drawing a link over an unknown hop", () => { + const paths = buildPacketPaths(detail([hop("0"), { confidence: "none", nodes: [] }, hop("2")])); + const data = packetPathsToFeatures(paths, null); + expect(data.lines.features).toHaveLength(0); + expect(data.points.features).toHaveLength(2); + expect(resolvedPathNodes([hop("0"), { confidence: "none", nodes: [] }, hop("2")])).toEqual([]); +}); diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 86b6514c..ec0a7aff 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -26,9 +26,9 @@ describe("buildPacketPaths", () => { const hops = [hop("unknown", 0, 0), candidates, hop("end", -75, 0)]; const ordinary = buildPacketPaths(detail([obs(1, hops)])); const trace = buildPacketPaths(detail([], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: hops } as Partial)); - for (const paths of [ordinary, trace]) { - expect(paths[0]!.points.map((p) => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]); - } + expect(ordinary).toEqual([]); expect(trace).toEqual([]); + const valid = buildPacketPaths(detail([obs(1, [hop("start", 0, 45), hop("end", -75, 0)])])); + expect(valid[0].points.map(p => [p.lng, p.lat])).toEqual([[0, 45], [-75, 0]]); expect(buildPacketPaths(detail([obs(1, [hop("unknown", 0, 0), hop("one", -75, 45)])]))).toEqual([]); }); @@ -144,19 +144,18 @@ describe("buildPacketPaths", () => { expect(buildPacketPaths(d)).toEqual([]); }); - it("uses the first located candidate for an ambiguous relay hop", () => { + it("does not turn the only located candidate into a resolved identity", () => { const multi: ResolvedHop = { confidence: "ambiguous", nodes: [ { id: "unlocated", publicKey: "p0" }, // no coords — skipped - { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // first with coords — used + { id: "located", publicKey: "p1", longitude: -78, latitude: 44 }, // location does not disambiguate identity ], }; const d = detail([ obs(1, [multi, hop("relay2", -77, 45)], { observerId: "obs-1", propagationTimeMs: 100 }), ]); - const [path] = buildPacketPaths(d); - expect(path!.points.map((p) => p.id)).toEqual(["located", "relay2"]); + expect(buildPacketPaths(d)).toEqual([]); }); it("omits observations that resolve to fewer than 2 located hops", () => { @@ -214,3 +213,9 @@ describe("packetPathsToFeatures", () => { expect(points.features[1]!.properties.title).toBe("Repeater North"); }); }); + +it("resumes a confirmed segment after returning to the same node across a gap", () => { + const paths = buildPacketPaths(detail([obs(1, [hop("a", -79, 43), hop("missing"), hop("a", -79, 43), hop("b", -75, 45)])])); + expect(paths[0].points.map(point => point.id)).toEqual(["a", "b"]); + expect(paths[0].points[1].breakBefore).toBeUndefined(); +}); diff --git a/tests/features/map/useMapBordersData.refresh.test.tsx b/tests/features/map/useMapBordersData.refresh.test.tsx new file mode 100644 index 00000000..5add918f --- /dev/null +++ b/tests/features/map/useMapBordersData.refresh.test.tsx @@ -0,0 +1,29 @@ +import { describe, expect, it, vi } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { useMapBordersData } from "../../../src/features/map/useMapBordersData"; +import type { IataBorder } from "../../../src/api/client"; + +const state = vi.hoisted(() => ({ data: null as IataBorder | null, updated: 0 })); +vi.mock("@tanstack/react-query", () => ({ useQueries: () => [{ data: state.data, dataUpdatedAt: state.updated }] })); + +const border = (lng: number): IataBorder => ({ type: "Feature", properties: {}, geometry: { type: "Polygon", coordinates: [[[lng, 0], [lng + 1, 0], [lng + 1, 1], [lng, 0]]] } }); + +describe("border refresh", () => { + it("replaces an already-loaded polygon when its query refreshes", () => { + state.data = border(0); state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YKF"], true)); + const previous = result.current; + state.data = border(5); state.updated = 2; + rerender(); + expect(result.current).not.toBe(previous); + expect(result.current.features[0]?.geometry).toEqual(border(5).geometry); + }); + it("adds a configured border after an earlier empty response", () => { + state.data = null; state.updated = 3; + const { result, rerender } = renderHook(() => useMapBordersData(["YOW"], true)); + expect(result.current.features).toHaveLength(0); + state.data = border(10); state.updated = 4; + rerender(); + expect(result.current.features).toHaveLength(1); + }); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx new file mode 100644 index 00000000..119445d4 --- /dev/null +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -0,0 +1,87 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverComparison } from "../../../src/features/observers/ObserverComparison"; +import { getObserverActivity, getObserverComparison } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity } from "../../../src/features/stats/types"; + +const A = "11111111-1111-1111-1111-111111111111"; +const B = "22222222-2222-2222-2222-222222222222"; +const until = Math.floor(Date.now() / 3_600_000) * 3_600_000; +const observer = { id: A, displayName: "Roof A", brokers: [], iata: "YOW" } as unknown as Observer; +const activity: ObserverActivity = { range: "72h", interval: "1h", windowStart: until - 259_200_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: unknown }) => {JSON.stringify(option)} })); +vi.mock("../../../src/api/client", async original => ({ + ...await original(), + getObserver: vi.fn(async (id: string) => ({ ...observer, id, displayName: "Roof B" })), + getObserversPage: vi.fn(async () => ({ items: [{ ...observer, id: B, displayName: "Roof B" }], hasMore: false })), + getObserverActivity: vi.fn(), getObserverComparison: vi.fn(), +})); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, summary: { ...activity.summary!, recordedPackets: 23 } }); + vi.mocked(getObserverComparison).mockResolvedValue({ observerA: A, observerB: B, since: activity.windowStart!, until, onlyA: 2, both: 3, onlyB: 4, totalPackets: 9 }); +}); +function view(id = B, data = activity, anchor: number | null = until) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const props = { observerA: observer, activityA: data, range: "3d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + return { ...render(, { wrapper }), props }; +} +it("aligns activity and retained overlap to the primary observer's effective window", async () => { + view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + expect(screen.getByText("19")).toBeInTheDocument(); + expect(getObserverActivity).toHaveBeenCalledWith(B, "72h", "1h", until); + expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal)); + expect(screen.getByText("Retained flood-packet overlap")).toBeInTheDocument(); + expect(screen.getByText(/Raw packet history may expire/)).toBeInTheDocument(); + expect(screen.getByTestId("chart").textContent).toContain('"name":"A"'); + expect(screen.getByTestId("chart").textContent).toContain('"name":"B"'); +}); +it("does not compare mismatched or old-server windows", async () => { + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, windowEnd: until - 3_600_000 }); + view(); + expect(await screen.findByText(/Aligned summaries are unavailable/)).toBeInTheDocument(); + expect(getObserverComparison).not.toHaveBeenCalled(); + expect(screen.queryByTestId("chart")).not.toBeInTheDocument(); +}); +it.each([A, "broken-id", ""]) ("never requests overlap for an invalid or missing partner: %s", async id => { + view(id); + expect((await screen.findAllByText(id ? /Choose a different observer/ : /Choose an observer to compare/)).length).toBeGreaterThan(0); + expect(getObserverActivity).not.toHaveBeenCalled(); + expect(getObserverComparison).not.toHaveBeenCalled(); +}); +it("rejects a bad anchor and offers a fresh common window", async () => { + const { props } = view(B, activity, null); + expect(await screen.findByText(/This comparison time is invalid/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + expect(props.onRefresh).toHaveBeenCalled(); + expect(getObserverActivity).not.toHaveBeenCalled(); +}); +it("clears the previous partner's totals while the next request is pending", async () => { + const { props, rerender } = view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + vi.mocked(getObserverActivity).mockImplementation(() => new Promise(() => {})); + rerender(); + await waitFor(() => expect(screen.queryByText("23")).not.toBeInTheDocument()); + expect(screen.queryByText("9 distinct flood packets")).not.toBeInTheDocument(); +}); + +it("refreshes the current window without requesting the obsolete window after re-anchoring", async () => { + const { props } = view(); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledTimes(1)); + const activityCalls = vi.mocked(getObserverActivity).mock.calls.length; + props.onRefresh.mockReturnValue(until + 3_600_000); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + + props.onRefresh.mockReturnValue(until); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + await waitFor(() => expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls + 1)); + expect(getObserverComparison).toHaveBeenCalledTimes(2); +}); diff --git a/tests/features/observers/ObserverDetailPanel.test.tsx b/tests/features/observers/ObserverDetailPanel.test.tsx index 7d332ae4..4e7c7b7f 100644 --- a/tests/features/observers/ObserverDetailPanel.test.tsx +++ b/tests/features/observers/ObserverDetailPanel.test.tsx @@ -101,7 +101,10 @@ describe("ObserverDetailPanel adverts", () => { const { onAnalyzePacket } = renderPanel(); - fireEvent.click(await screen.findByText("Node Alpha")); - expect(onAnalyzePacket).toHaveBeenCalledWith("hash-1"); + const row = await screen.findByRole("button", { name: /Node Alpha/ }); + expect(row.tagName).toBe("BUTTON"); + row.focus(); + fireEvent.click(row); + expect(onAnalyzePacket).toHaveBeenCalledWith("hash-1", 1); }); }); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx new file mode 100644 index 00000000..d53089ef --- /dev/null +++ b/tests/features/observers/ObserverPage.test.tsx @@ -0,0 +1,111 @@ +import { describe, it, expect, vi } from "vitest"; +import "../../../src/i18n"; +import { useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { ObserverPage } from "../../../src/features/observers/ObserverPage"; + +vi.mock("../../../src/features/observers/ObserverTable", () => ({ + ObserverTable: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => { + const [search, setSearch] = useState(""); + return ( +
+ setSearch(e.target.value)} /> + +
+ ); + }, +})); +vi.mock("../../../src/features/stats/ObserverTab", () => ({ + ObserverTab: ({ + selectedObserverId, + range, + comparison, + }: { + selectedObserverId: string; + range: string; + comparison?: { until: number | null; onRefresh: () => void }; + }) => ( + <> +

+ Dashboard {selectedObserverId} {range} +

+ {comparison && ( + <> + {String(comparison.until)} + + + )} + + ), +})); +function Location() { + const l = useLocation(); + const go = useNavigate(); + return ( + <> + {l.search} + + + + ); +} +function view(url = "?tab=Observers&iata=YOW") { + return render( + + + + , + ); +} + +describe("Observer destination", () => { + it("opens a canonical dashboard and restores directory state with Back", async () => { + view(); + fireEvent.change(screen.getByLabelText("Directory search"), { target: { value: "roof" } }); + fireEvent.click(screen.getByText("Open A")); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 3d"); + expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); + expect(screen.getByRole("status").textContent).toContain("iata=YOW"); + fireEvent.click(screen.getByText("Browser back")); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Directory search")).toHaveValue("roof"); + fireEvent.click(screen.getByText("Browser forward")); + expect(await screen.findByRole("heading")).toHaveTextContent("observer-a"); + }); + it("restores a deep link and has a directory return without prior history", async () => { + view("?tab=Observers&observer=observer-b&range=30d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 3d"); + expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "3d"]); + fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); + expect(screen.getByLabelText("Directory search")).toBeVisible(); + }); +}); + +it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 8, 29, 12, 30)); + try { + view("?tab=Observers&observer=observer-a&range=24h"); + await screen.findByRole("heading"); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 15, 59, 59)); + fireEvent.click(screen.getByRole("button", { name: "Compare with…" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 15))); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 16, 0, 1)); + fireEvent.click(screen.getByRole("button", { name: "Refresh comparison" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 16))); + fireEvent.click(screen.getByRole("button", { name: "Close comparison" })); + expect(screen.queryByTestId("comparison-until")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).not.toHaveTextContent("compareUntil"); + } finally { + clock.mockRestore(); + } +}); + +it("rejects duplicate, malformed and future comparison anchors", async () => { + const viewResult = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=bad"); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + viewResult.unmount(); + view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); +}); diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx new file mode 100644 index 00000000..189f95f7 --- /dev/null +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { ObserverSummary } from "../../../src/features/observers/ObserverSummary"; +import type { Observer } from "../../../src/features/observers/types"; + +const observer = { id: "observer", displayName: "Roof", iata: "YKF", brokers: [], lastStatusAt: Date.now() - 600_000 } as unknown as Observer; + +describe("compact observer header", () => { + it("uses an accessible stale icon and omits the traffic text badge", () => { + render(); + expect(screen.getByRole("img", { name: "Status is stale" })).toHaveClass("text-warn"); + expect(screen.queryByText("Status is stale")).not.toBeInTheDocument(); + expect(screen.queryByText(/No recent recorded packets|Packet freshness unavailable/)).not.toBeInTheDocument(); + }); + it("distinguishes recent and absent status without inventing telemetry", () => { + const { rerender } = render(); + expect(screen.getByRole("img", { name: "Recent status" })).toHaveClass("text-green"); + rerender(); + expect(screen.getByRole("img", { name: "No status report" })).toHaveClass("text-text-muted"); + }); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts new file mode 100644 index 00000000..691cb03b --- /dev/null +++ b/tests/features/observers/observer-navigation.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from "vitest"; +import { observerDestination, observerRange } from "../../../src/features/observers/observer-navigation"; +it("normalizes legacy analytics links without losing region",()=>{ + const p=observerDestination(new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"),"abc"); + expect(p.get("tab")).toBe("Observers");expect(p.get("observer")).toBe("abc");expect(p.get("range")).toBe("3d");expect(p.get("iata")).toBe("YOW");expect(p.has("observerId")).toBe(false);expect(p.has("statsTab")).toBe(false); +}); +it("drops unrelated investigation selectors and bounds range",()=>{ + const p=observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"),"b"); + expect(p.toString()).toBe("tab=Observers&observer=b&range=3d");expect(observerRange("invalid")).toBe("3d"); +}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 8dc1853d..edc34149 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; +import i18n from "../../../src/i18n"; import { MemoryRouter, useLocation } from "react-router-dom"; import { PacketAnalyzerDrawer } from "../../../src/features/packets/PacketAnalyzerDrawer"; import type { PacketDetail } from "../../../src/types/api"; @@ -11,6 +12,13 @@ function LocationProbe() { } describe("PacketAnalyzerDrawer close", () => { + it("leaves the originating packet URL unchanged when used in an overlay", () => { + const onClose = vi.fn(); + render(); + fireEvent.click(screen.getByLabelText("Close analyzer")); + expect(screen.getByTestId("search")).toHaveTextContent("hash=origin&analyze=1&observation=7"); + expect(onClose).toHaveBeenCalledOnce(); + }); it("removes ?analyze but keeps ?hash, and calls onClose", () => { const onClose = vi.fn(); render( @@ -30,6 +38,54 @@ describe("PacketAnalyzerDrawer close", () => { }); }); +describe("packet reception evidence", () => { + const reports = () => { + const d = makeDetail([hop("a", -79, 43), hop("b", -75, 45)]); + d.observations[0].pathBytes = "aabb"; + d.observations[0].observerName = "Alpha"; + d.observations.push({ ...d.observations[0], id: 2, observerId: "beta", observerName: "Beta" }); + return d; + }; + it("restores a shared report and targets its observer and map", () => { + const onViewObserver = vi.fn(); const onViewPath = vi.fn(); + render( {}} onViewObserver={onViewObserver} onViewPath={onViewPath} />); + const section = screen.getByRole("region", { name: "Reception evidence" }); + expect(within(section).getByText("Path 1").closest("details")).toHaveAttribute("open"); + const beta = within(section).getByText(/Beta/).closest("li")!; + expect(within(beta).getByRole("button", { name: "Inspect report" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta"); + fireEvent.click(within(beta).getByRole("button", { name: "Map report" })); expect(onViewPath).toHaveBeenCalledWith("beta"); + expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep"); + }); + it("reports an expired selection without quietly showing another report", () => { + render( {}} />); + expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable"); + expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); + }); + it("keeps route evidence selections strict without requiring packet URL state", () => { + render( {}} />); + expect(screen.getByRole("alert")).toHaveTextContent("Selected report is unavailable"); + expect(screen.queryByText("Raw Packet")).not.toBeInTheDocument(); + }); + it("explains TRACE and missing-report semantics in French", async () => { + const d = reports(); d.header.payloadType = PayloadType.TRACE; d.observations = []; + await i18n.changeLanguage("fr"); + render( {}} />); + expect(screen.getByRole("region", { name: "Rapports de réception" })).toHaveTextContent("itinéraire prévu"); + expect(screen.getByText("Aucun rapport conservé pour ce paquet.")).toBeInTheDocument(); + }); + it("keeps a later selected path visible while bounding the initial list", () => { + const d = reports(); + d.observations = Array.from({ length: 12 }, (_, i) => ({ ...d.observations[0], id: i + 1, observerId: `observer-${i}`, pathBytes: i.toString(16).padStart(4, "0") })); + render( {}} />); + const section = screen.getByRole("region", { name: "Reception evidence" }); + expect(within(section).getByText("Path 12").closest("details")).toHaveAttribute("open"); + expect(within(section).queryByText("Path 4")).not.toBeInTheDocument(); + fireEvent.click(within(section).getByRole("button", { name: "Show all 12 path groups" })); + expect(within(section).getByText("Path 4")).toBeInTheDocument(); + }); +}); + const hop = (id: string, lng: number, lat: number) => ({ confidence: "high" as const, nodes: [{ id, publicKey: "pk", longitude: lng, latitude: lat }] }); function makeDetail(resolvedPath: unknown[]): PacketDetail { diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 49d0ef21..1c75deda 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; import type { LatestObserver, PacketSummary } from "../../../src/types/api"; @@ -13,6 +13,16 @@ const obs = (over: Partial = {}): LatestObserver => ({ id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over, }); +afterEach(() => vi.restoreAllMocks()); + +const ambiguousSource = { + confidence: "ambiguous" as const, + nodes: [ + { id: "source-a", name: "Alpha", publicKey: "aa010203" }, + { id: "source-b", name: "Beta", publicKey: "aa040506" }, + ], +}; + describe("PacketEndpoints", () => { it("renders a single n/a when there is no observer at all", () => { render(); @@ -84,4 +94,39 @@ describe("PacketEndpoints", () => { expect(screen.getByText("TRACE 2ca2a79c").className).toContain("text-text-muted"); expect(screen.queryByText("n/a")).not.toBeInTheDocument(); }); + + it("shows all ambiguous candidates on hover, with an additional-match count", () => { + render(); + const trigger = screen.getByText("Alpha +1").parentElement!; + fireEvent.mouseEnter(trigger); + const tip = screen.getByRole("tooltip"); + expect(within(tip).getByText("Alpha")).toBeInTheDocument(); + expect(within(tip).getByText("Beta")).toBeInTheDocument(); + expect(trigger).toHaveAttribute("title", "Alpha, Beta"); + expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); + }); + + it("keeps the containing packet row as the only keyboard button", () => { + render(); + expect(screen.getAllByRole("button")).toHaveLength(1); + const chip = screen.getByText("Alpha +1").parentElement!; + expect(chip).not.toHaveAttribute("tabindex"); + expect(chip).toHaveAttribute("title", "Alpha, Beta"); + }); + + it("opens all candidates on touch without activating the packet row", () => { + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })); + const select = vi.fn(); + render(
); + fireEvent.click(screen.getByText("Alpha +1").parentElement!); + expect(within(screen.getByRole("tooltip")).getByText("Beta")).toBeInTheDocument(); + expect(select).not.toHaveBeenCalled(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("renders resolved endpoints without requiring physical path metadata", () => { + render(); + expect(screen.getByText("Source")).toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PacketList.test.tsx b/tests/features/packets/PacketList.test.tsx index e4244ae9..eeb3b518 100644 --- a/tests/features/packets/PacketList.test.tsx +++ b/tests/features/packets/PacketList.test.tsx @@ -67,7 +67,7 @@ vi.mock("../../../src/features/packets/PacketVirtualList", () => ({ }) => (
{String(expandedHash)}
- + {packets.map((p) => ( {params.toString()} + {}} onAnalyzePacket={inspect} onViewObserver={observer} onViewNode={node} />; +} +function mount(url = "/?tab=Routes") { + render(); +} +beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); }); +describe("retained route evidence", () => { + it("normalizes an old month link to 72 hours and offers only retained periods", async () => { + mount("/?routeRange=30d"); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything()); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "7 days" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "30 days" })).not.toBeInTheDocument(); + }); + it("does not request a shared raw-evidence period longer than three days", async () => { + mount("/?routeSince=1700000000000&routeUntil=1700345600000"); + expect(await screen.findByRole("alert")).toBeInTheDocument(); + expect(getRouteEvidence).not.toHaveBeenCalled(); + }); + it("opens the exact report and observer, names the saved route, and pins cursor pages", async () => { + mount(); + await screen.findByText("Garden"); + expect(screen.getByText(/not proof of the same physical route/)).toBeInTheDocument(); + expect(screen.getByText("North")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Inspect report" })); + expect(inspect).toHaveBeenCalledWith("aabb", 17); + fireEvent.click(screen.getByRole("button", { name: "Inspect observer" })); + expect(observer).toHaveBeenCalledWith("o1"); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + fireEvent.click(screen.getByRole("button", { name: "Load more reports" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { pageCursor: "pinned-cursor", limit: 50 }, expect.anything())); + }); + it("replaces results when the route changes, even while its request is pending", async () => { + mount(); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockReturnValue(new Promise(() => {})); + fireEvent.click(screen.getByText("Other route")); + expect(screen.queryByText("Garden")).not.toBeInTheDocument(); + expect(screen.getByText("Loading retained reports…")).toBeInTheDocument(); + }); + it("uses shared fixed boundaries, then drops them when a new range is selected", async () => { + mount("/?routeSince=1700000000000&routeUntil=1700086400000"); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700086400000, limit: 50 }, expect.anything()); + expect(screen.getByText("Shared time window")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything())); + expect(screen.getByText(/routeRange=3d/)).not.toHaveTextContent("routeSince"); + }); + it("does not replace an invalid shared window with an unrelated default", async () => { + mount("/?routeSince=bad"); + expect(await screen.findByRole("alert")).toHaveTextContent("Invalid shared time window"); + expect(getRouteEvidence).not.toHaveBeenCalled(); + }); + it("explains expired or absent evidence without presenting the historical counter as a total", async () => { + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + mount(); + expect(await screen.findByText(/No retained reports match/)).toBeInTheDocument(); + expect(screen.queryByText("200")).not.toBeInTheDocument(); + }); + it("distinguishes unavailable saved prefixes and translates the panel", async () => { + await i18n.changeLanguage("fr"); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, matchAvailable: false, items: [], hasMore: false }); + mount(); + expect(await screen.findByText(/préfixes enregistrés ne permettent pas/)).toBeInTheDocument(); + expect(screen.getByText("Rapports conservés")).toBeInTheDocument(); + }); + it("keeps a failed next page separate from the reports already loaded", async () => { + mount(); await screen.findByText("Garden"); + vi.mocked(getRouteEvidence).mockRejectedValue(new Error("offline")); + fireEvent.click(screen.getByRole("button", { name: "Load more reports" })); + expect(await screen.findByRole("alert")).toHaveTextContent("Could not load retained reports"); + expect(screen.getByText("Garden")).toBeInTheDocument(); + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [], hasMore: false }); + fireEvent.click(screen.getByRole("button", { name: "Retry" })); + await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); + }); +}); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index 1a3fd64d..f1172333 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { RouteTable } from "../../../src/features/routes/RouteTable"; +import { MemoryRouter } from "react-router-dom"; import { RegionProvider } from "../../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../../src/hooks/region-selection"; import { @@ -11,6 +12,7 @@ import { searchCrossIATARoutes, getIatas, getRegions, + getRegion, } from "../../../src/api/client"; import type { KnownRoute, CrossIATARoute } from "../../../src/types/api"; @@ -20,8 +22,11 @@ vi.mock("../../../src/api/client", () => ({ searchCrossIATARoutes: vi.fn(), getIatas: vi.fn(), getRegions: vi.fn(), + getRegion: vi.fn(), })); +vi.mock("../../../src/features/routes/RouteEvidencePanel", () => ({ RouteEvidencePanel: ({ pathKey }: { pathKey: string }) =>
{pathKey}
})); + const mockGetKnownRoutesPage = vi.mocked(getKnownRoutesPage); const mockSearchKnownRoutes = vi.mocked(searchKnownRoutes); const mockSearchCrossIATARoutes = vi.mocked(searchCrossIATARoutes); @@ -34,7 +39,7 @@ function renderTable(selection = ALL_REGIONS) { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const wrapper = ({ children }: { children: ReactNode }) => ( - {children} + {children} ); render(, { wrapper }); @@ -139,3 +144,14 @@ describe("RouteTable search", () => { expect(await screen.findByText("42")).toBeInTheDocument(); }); }); + +it("preserves a shared saved route while a named region resolves", async () => { + vi.mocked(getRegions).mockResolvedValue([{ id: 1, slug: "ontario", name: "Ontario" }]); + vi.mocked(getRegion).mockResolvedValue({ id: 1, slug: "ontario", name: "Ontario", iatas: ["YOW"] }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + await waitFor(() => expect(getRegion).toHaveBeenCalledWith(1)); + await waitFor(() => expect(getKnownRoutesPage).toHaveBeenCalledWith(expect.objectContaining({ iata: "YOW" }))); + expect(screen.getByTestId("saved-route-selection")).toHaveTextContent("shared-key"); + client.clear(); +}); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx index 4b5b434a..c7e29aaa 100644 --- a/tests/features/stats/CompareObserversTab.test.tsx +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -35,6 +35,17 @@ beforeEach(() => { }); describe("observer comparison", () => { + it("rejects a raw packet comparison beyond three days", async () => { + mount(); + await screen.findAllByRole("option", { name: /Rooftop/ }); + fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); + fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } }); + fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-05T00:00" } }); + fireEvent.click(screen.getByRole("button", { name: "Compare" })); + expect(await screen.findByRole("alert")).toHaveTextContent("3 days or less"); + expect(getObserverComparison).not.toHaveBeenCalled(); + }); it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { mount(); await screen.findAllByRole("option", { name: /Rooftop/ }); @@ -42,11 +53,11 @@ describe("observer comparison", () => { fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); fireEvent.change(screen.getByLabelText("Observer B"), { target: { value: b } }); fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); - fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-02-15T00:00" } }); + fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-03T00:00" } }); expect(getObserverComparison).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Compare" })); await waitFor(() => expect(getObserverComparison).toHaveBeenCalledWith(["YVR"], { - observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-02-15T00:00").getTime(), + observerA: a, observerB: b, since: new Date("2026-01-01T00:00").getTime(), until: new Date("2026-01-03T00:00").getTime(), }, expect.any(AbortSignal))); expect(screen.getByLabelText("Current URL").textContent).toContain(`compareA=${a}`); }); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index 20d783a4..3fc0dafc 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MeshTab } from "../../../src/features/stats/MeshTab"; import { getStatsOverview, getStatsObservations, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; @@ -29,7 +29,7 @@ beforeEach(() => { }); afterEach(() => { clients.splice(0).forEach((client) => client.clear()); }); -function card(title: string) { return screen.getByText(title).parentElement!.parentElement!; } +function card(title: string) { return title === "Scopes · selected region · retained data" ? screen.getByText("Scopes", { exact: true }).closest("details")! : screen.getByText(title).parentElement!.parentElement!; } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); @@ -44,6 +44,7 @@ function mount(range: StatsRange = "24h") { } async function loaded() { await waitFor(() => expect(screen.getAllByTestId("chart")).toHaveLength(6)); await screen.findByText("#old"); } + it("hides all previous-region values and sparklines until their own requests resolve", async () => { const { rerender, container } = mount(); await loaded(); const next = deferred(); @@ -153,3 +154,12 @@ it("distinguishes initial loading from successful empty results and zero totals" expect(screen.getAllByText("No data")).toHaveLength(7); expect(within(card("Total packets")).getByText("0")).toBeInTheDocument(); }); + +it("keeps the scope list collapsed until requested", async () => { + mount(); await loaded(); + const scopeList = screen.getByText("Scopes", { exact: true }).closest("details")!; + expect(scopeList).not.toHaveAttribute("open"); + fireEvent.click(screen.getByText("Scopes", { exact: true })); + expect(scopeList).toHaveAttribute("open"); + expect(within(scopeList).getByText("#old")).toBeVisible(); +}); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index a6006f82..7006f09f 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { ObserverTab } from "../../../src/features/stats/ObserverTab"; +import i18n from "../../../src/i18n"; import { ApiError } from "../../../src/api/client"; import type { Observer } from "../../../src/features/observers/types"; import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../../../src/features/stats/types"; @@ -75,7 +76,10 @@ const point = (t: number, p: Partial): TelemetryPoint => ({ const telemetry: ObserverTelemetry = { range: "24h", interval: "1h", - points: [point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 })], + points: [ + point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), + point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 }), + ], }; const activity: ObserverActivity = { @@ -88,11 +92,17 @@ const activity: ObserverActivity = { function renderTab() { const qc = new QueryClient(); - const wrapper = ({ children }: { children: ReactNode }) => {children}; - return render( {}} wsManager={{} as WsManager} />, { wrapper }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render( + {}} wsManager={{} as WsManager} />, + { wrapper }, + ); } beforeEach(() => { + observer.brokers = []; telemetryResult.data = telemetry; telemetryResult.isLoading = false; telemetryResult.isError = false; @@ -105,7 +115,7 @@ beforeEach(() => { }); describe("ObserverTab", () => { - it("shows the latest reported RX and TX airtime as percent in the header", () => { + it("shows the latest reported RX and TX airtime as percent beside the airtime chart", () => { renderTab(); // +54 s RX and +36 s TX over the hour between the two reports expect(screen.getByText(/RX 1\.5%/)).toBeInTheDocument(); @@ -115,9 +125,9 @@ describe("ObserverTab", () => { it("renders the heard charts when the server has activity for the observer", () => { renderTab(); expect(screen.getByText(/channel busy/i)).toBeInTheDocument(); - expect(screen.getByText(/heard per 15 min/i)).toBeInTheDocument(); - expect(screen.getByText(/snr heard/i)).toBeInTheDocument(); - expect(screen.getByText(/payload types heard/i)).toBeInTheDocument(); + expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument(); + expect(screen.getByText(/received signal/i)).toBeInTheDocument(); + expect(screen.getByText(/packet-type mix/i)).toBeInTheDocument(); }); it("names the radio settings the busy percent assumes, in the same form as the header", () => { @@ -141,20 +151,83 @@ describe("ObserverTab", () => { expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument(); }); - it("shows the heard cards as loading while the previous selection's data is only a placeholder", () => { - activityResult.data = { ...activity, points: [] }; - activityResult.isPlaceholderData = true; - activityResult.dataUpdatedAt = 0; + it("keeps the last successful activity visible after a failed poll", () => { + activityResult.isError = true; + activityResult.error = new Error("temporary failure"); renderTab(); - expect(screen.getAllByText("Loading…").length).toBeGreaterThan(0); - expect(screen.queryByText(/no packets heard/i)).not.toBeInTheDocument(); - expect(screen.queryByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toBeInTheDocument(); // header only + expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument(); + expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); }); it("shows one empty card instead of flat charts when nothing was heard", () => { activityResult.data = { ...activity, payloadTypes: [], points: [] }; renderTab(); - expect(screen.getByText(/no packets heard/i)).toBeInTheDocument(); + expect(screen.getByText(/no packets recorded/i)).toBeInTheDocument(); expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); }); }); + +describe("Observer dashboard hierarchy", () => { + it("uses period metrics rather than the legacy presence counter", () => { + activityResult.data = { + ...activity, + windowStart: Date.now() - 86400000, + windowEnd: Date.now(), + generatedAt: Date.now(), + source: "raw", + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: Date.now() - 7200000, + lastCompleteHourEnd: Date.now() - 3600000, + latestRecordedAt: Date.now() - 60000, + }, + }; + renderTab(); + const cards = screen.getByRole("list", { name: "Observer metrics" }); + expect(within(cards).getByText("9")).toBeInTheDocument(); + expect(within(cards).getAllByRole("listitem")).toHaveLength(6); + expect(within(cards).queryByText("12")).not.toBeInTheDocument(); + expect( + screen.getByText("Recorded packets per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("does not invent zero packet metrics on an older server", () => { + renderTab(); + expect(screen.getByText(/Packet summary unavailable/)).toBeInTheDocument(); + }); + it("provides French monitoring labels", async () => { + await i18n.changeLanguage("fr"); + renderTab(); + expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument(); + expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); + }); +}); + +it("keeps packet metrics without the removed traffic text badge", () => { + const now = Date.now(); + observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; + activityResult.data = { + ...activity, + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: now - 7200000, + lastCompleteHourEnd: now - 3600000, + latestRecordedAt: now - 3600000, + }, + }; + renderTab(); + expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); + expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); +}); + +it("omits the duplicate observer picker and marked explanatory sections", () => { + renderTab(); + expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); + expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); + expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); +}); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx index 2d0efc38..52e3d8bc 100644 --- a/tests/features/stats/StatsSubHeader.test.tsx +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -6,14 +6,29 @@ import i18n from "../../../src/i18n"; afterEach(() => vi.restoreAllMocks()); describe("translated analytics controls", () => { + it("keeps archived analytics ranges without the legacy Observer destination", async () => { + const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() }; + const { rerender } = render(); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Observer", exact: true })).not.toBeInTheDocument(); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("button", { name: "3 j" })); + expect(props.onRangeChange).toHaveBeenCalledWith("3d"); + rerender(); + expect(screen.queryByRole("button", { name: "7 j" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); + }); + it("keeps section and range identifiers when labels change", async () => { const onTabChange = vi.fn(), onRangeChange = vi.fn(); - render(); - expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + render(); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); await act(() => i18n.changeLanguage("fr")); expect(screen.getByRole("group", { name: "Section des statistiques" })).toBeInTheDocument(); const range = screen.getByRole("group", { name: "Période" }); - expect(within(range).getByRole("button", { name: "7 j" })).toHaveAttribute("aria-pressed", "true"); + expect(within(range).getByRole("button", { name: "3 j" })).toHaveAttribute("aria-pressed", "true"); expect(onTabChange).not.toHaveBeenCalled(); expect(onRangeChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Chemins et hachages" })); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 2a523c2d..4b65d4d9 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -11,6 +11,15 @@ vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh c vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } +it("normalizes old seven-day links without the removed retention banner", () => { + render(); + expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); + expect(screen.queryByText(/Hourly analytics keep summaries for up to 30 days/)).not.toBeInTheDocument(); +}); + it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { render(); expect(screen.getByText("Paths range 24h")).toBeInTheDocument(); @@ -24,8 +33,8 @@ it("opens Signal from a shared URL and retains regional state when changing rang render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "7d" })); - expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); }); diff --git a/tests/features/stats/useTelemetry.test.ts b/tests/features/stats/useTelemetry.test.ts index 9dd576f4..d1c7016e 100644 --- a/tests/features/stats/useTelemetry.test.ts +++ b/tests/features/stats/useTelemetry.test.ts @@ -5,6 +5,7 @@ import { ApiError } from "../../../src/api/client"; describe("activityParamsFor", () => { it("pairs each stats range with the bucket the charts are sized for", () => { expect(activityParamsFor("24h")).toEqual({ range: "24h", interval: "15m" }); + expect(activityParamsFor("3d")).toEqual({ range: "72h", interval: "1h" }); expect(activityParamsFor("7d")).toEqual({ range: "168h", interval: "1h" }); expect(activityParamsFor("30d")).toEqual({ range: "720h", interval: "6h" }); }); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 328fdd6e..25c9f57c 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Observers", "Packets", "Channels", "Routes", "Traces", "Analytics", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Observers", "Packets", "Channels", "Routes", "Traces", "Analytics", ]); }); From 9058665af025bbc1e0cbb0f5b30d8468aeadf676 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:11:10 -0400 Subject: [PATCH 116/163] build(deps-dev): bump brace-expansion from 5.0.9 to 5.0.12 (#100) Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.9 to 5.0.12. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.9...v5.0.12) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.12 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 45762963..9dc2842c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2282,9 +2282,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { From 39b85a903223c26bc17b6a9a8813bc555b3be498 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:35:07 -0400 Subject: [PATCH 117/163] fix: follow up on the #75-#99 stack review (#101) --- src/App.tsx | 17 +++- src/features/InvestigationPanels.tsx | 5 +- src/features/nodes/NodeDetailPanel.tsx | 12 ++- src/features/observers/ObserverPage.tsx | 4 +- src/features/observers/ObserverTable.tsx | 22 +++-- src/features/observers/observer-navigation.ts | 2 +- src/features/packets/PacketEndpoints.tsx | 11 ++- src/features/packets/PathData.tsx | 1 - src/features/routes/RouteEvidencePanel.tsx | 8 +- src/features/stats/CompareObserversTab.tsx | 1 - src/features/stats/StatsOverview.tsx | 9 +- src/features/stats/StatsSubHeader.tsx | 2 +- src/features/stats/types.ts | 5 +- src/features/stats/useTelemetry.ts | 3 - src/i18n/locales/en.json | 25 +---- src/i18n/locales/fr.json | 25 +---- tests/App.observerInvestigation.test.tsx | 2 +- tests/App.quickObserver.test.tsx | 8 +- tests/App.viewOnMap.test.tsx | 95 +++++++++++++++++++ .../channels/MessagePanel.scope.test.tsx | 46 ++++----- tests/features/nodes/NodeDetailPanel.test.tsx | 32 +++++++ .../observers/ObserverComparison.test.tsx | 6 +- .../features/observers/ObserverPage.test.tsx | 17 +++- .../features/observers/ObserverTable.test.tsx | 46 +++++++++ .../observers/observer-navigation.test.ts | 40 ++++++-- .../features/packets/PacketEndpoints.test.tsx | 4 +- .../features/packets/PacketTableRow.test.tsx | 14 +-- tests/features/packets/PathData.test.tsx | 7 ++ .../routes/RouteEvidencePanel.test.tsx | 30 ++++-- .../stats/CompareObserversTab.test.tsx | 6 +- tests/features/stats/ObserverTab.test.tsx | 48 +++++----- tests/features/stats/StatsSubHeader.test.tsx | 20 ++-- .../stats/analytics-navigation.test.tsx | 17 ++-- tests/features/stats/useTelemetry.test.ts | 1 - 34 files changed, 408 insertions(+), 183 deletions(-) create mode 100644 tests/App.viewOnMap.test.tsx create mode 100644 tests/features/observers/ObserverTable.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 8c98e732..5f87031d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -223,7 +223,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: }, { replace: true }); }, [setSearchParams]); - const handleTabChange = (tab: string) => { + const handleTabChange = (tab: string, mapFocus?: { lat: number; lng: number }) => { setPanels([]); dashboardFocus.current = null; onExitVisit(); @@ -240,6 +240,13 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); if (tab !== "Observers") next.delete("observer"); + // lat/lng/zoom only seed the map on mount, so they'd re-frame it on every later visit + if (tab !== "Map" || mapFocus) for (const key of ["lat", "lng", "zoom"]) next.delete(key); + if (mapFocus) { + next.set("lat", mapFocus.lat.toFixed(5)); + next.set("lng", mapFocus.lng.toFixed(5)); + next.set("zoom", "14"); + } // stats sub-state shouldn't haunt the URL on other tabs if (tab !== "Analytics" && tab !== "Observers") { next.delete("statsTab"); @@ -250,6 +257,11 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: }); }; + const handleViewOnMap = (nodeId: string, lat: number, lng: number) => { + handleTabChange("Map", { lat, lng }); + if (!isMobile) setSelectedNodeId(nodeId); + }; + const clearSelection = useCallback(() => { setSelectedNodeId(null); setPanels([]); @@ -346,9 +358,10 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: onViewObserver={viewObserver} onViewNode={setSelectedNodeId} onAnalyzePacket={viewPacket} + onViewOnMap={activeTab === "Map" ? undefined : (lat, lng) => handleViewOnMap(selectedNodeId, lat, lng)} /> )} - {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} />)} + {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)}
{observerVisit && } />}
diff --git a/src/features/InvestigationPanels.tsx b/src/features/InvestigationPanels.tsx index c402ad7a..66f7e380 100644 --- a/src/features/InvestigationPanels.tsx +++ b/src/features/InvestigationPanels.tsx @@ -20,9 +20,10 @@ export interface Investigation { } // Each panel stays mounted underneath the next one so its selection, scroll and trigger survive. -export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard }: { +export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard, onViewOnMap }: { target: InvestigationTarget; inactive: boolean; onClose: () => void; onOpen: (target: InvestigationTarget) => void; onObserverDashboard: (id: string) => void; + onViewOnMap?: (nodeId: string, lat: number, lng: number) => void; }) { const { t } = useTranslation(); const { data: detail, isLoading } = usePacketDetail(target.kind === "packet" ? target.hash : null); @@ -35,7 +36,7 @@ export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserv {target.kind === "observer" ? ( ) : target.kind === "node" ? ( - + onViewOnMap(target.id, lat, lng))} /> ) : ( void; onViewNode?: (nodeId: string) => void; onAnalyzePacket?: (hash: string) => void; + onViewOnMap?: (lat: number, lng: number) => void; } -export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket }: NodeDetailPanelProps) { +export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, onAnalyzePacket, onViewOnMap }: NodeDetailPanelProps) { const { data: node, isLoading } = useQuery({ queryKey: ["node", nodeId], queryFn: () => getNode(nodeId), @@ -145,6 +146,15 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o {node.lng != null && } {node.locationSource && }
+ {hasLocation && onViewOnMap && ( + + )} )} diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 9b85cd74..b860fc50 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -55,12 +55,12 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: {(id || onReturn) &&
{onReturn && } {id && <> - + } diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index b298424c..110fa1ed 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -114,6 +114,12 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const [typeFilter, setTypeFilter] = useState(""); const [brokerFilter, setBrokerFilter] = useState(""); const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set + // the compact sidebar only has a name search, so the directory's other filters sit out until it returns + const status = compact ? "" : statusFilter; + const type = compact ? "" : typeFilter; + const broker = compact ? "" : brokerFilter; + const scope = compact ? "" : scopeFilter; + const field = compact ? "name" : searchField; useTick(); // keep recency-derived status badges fresh @@ -130,8 +136,8 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, ); const queryKey = useMemo( - () => ["observers", regionKey, statusFilter, typeFilter, brokerFilter, search, searchField], - [regionKey, statusFilter, typeFilter, brokerFilter, search, searchField], + () => ["observers", regionKey, status, type, broker, search, field], + [regionKey, status, type, broker, search, field], ); // page the region's observers 50 at a time (filters stay server-side, in the query key); rows @@ -141,10 +147,10 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, queryFn: (cursor) => getObserversPage(iatas, { cursor, - status: statusFilter || undefined, - type: typeFilter || undefined, - broker: brokerFilter || undefined, - name: searchField === "name" ? search || undefined : undefined, + status: status || undefined, + type: type || undefined, + broker: broker || undefined, + name: field === "name" ? search || undefined : undefined, }), getId: observerId, keepPrevious: true, @@ -162,8 +168,8 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const scopeOptions = useScopes(); const displayObservers = useMemo( - () => (scopeFilter ? observers.filter((o) => o.scopes?.includes(scopeFilter)) : observers), - [observers, scopeFilter], + () => (scope ? observers.filter((o) => o.scopes?.includes(scope)) : observers), + [observers, scope], ); // patch the live status into the paged cache (mirrors NodeTable). A brand-new observer not on any diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index 39a7c7c3..b31869bb 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -1,6 +1,6 @@ import type { StatsRange } from "../stats/types"; -export const observerRange = (value: string | null): StatsRange => value === "24h" ? "24h" : "3d"; +export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d"; export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { const next = new URLSearchParams(params); diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index cd9e5dfb..1aabaa8a 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,4 +1,4 @@ -import type { PacketSummary, ResolvedHop } from "../../types/api"; +import type { PacketSummary, ResolvedHop, ResolvedNode } from "../../types/api"; import { PayloadType, type PathConfidence } from "../../types/enums"; import { HopPopover } from "./PathData"; @@ -9,12 +9,17 @@ const CONFIDENCE_CLASSES: Record = { none: "bg-text-muted/8 text-text-dim", }; +const nodeLabel = (node: ResolvedNode) => node.name ?? node.publicKey.slice(0, 8); + function Chip({ hop }: { hop: ResolvedHop }) { const node = hop.nodes[0]; - const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?"; + const label = node ? nodeLabel(node) : "?"; return ( - + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 59db7f9e..f0e62850 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -94,7 +94,6 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: { onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} onClick={hasHover ? undefined : toggle} - title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"} className="inline-flex min-w-0 max-w-full" > {children} diff --git a/src/features/routes/RouteEvidencePanel.tsx b/src/features/routes/RouteEvidencePanel.tsx index 5b270f01..7f7e4ec6 100644 --- a/src/features/routes/RouteEvidencePanel.tsx +++ b/src/features/routes/RouteEvidencePanel.tsx @@ -21,14 +21,14 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on const { t } = useTranslation(); const [params, setParams] = useSearchParams(); const [openedAt] = useState(Date.now); - const range = ["3d", "7d", "30d"].includes(params.get("routeRange") ?? "") ? "3d" : "24h"; + const range = params.get("routeRange") === "7d" ? "7d" : params.get("routeRange") === "30d" ? "30d" : "24h"; const rawSince = params.get("routeSince"), rawUntil = params.get("routeUntil"); const fixed = rawSince !== null || rawUntil !== null; const since = Number(rawSince), until = Number(rawUntil); - const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 3 * 86400000); + const invalid = fixed && (!rawSince || !rawUntil || !/^\d+$/.test(rawSince) || !/^\d+$/.test(rawUntil) || !Number.isSafeInteger(since) || !Number.isSafeInteger(until) || until <= since || until > openedAt || until - since > 30 * 86400000); const query = useInfiniteQuery({ queryKey: ["route-evidence", iata, pathKey, range, rawSince, rawUntil], - queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "3d" ? "72h" : "24h", limit: 50 }, signal), + queryFn: ({ pageParam, signal }) => getRouteEvidence(iata, pathKey, pageParam ? { pageCursor: pageParam, limit: 50 } : fixed ? { since, until, limit: 50 } : { range: range === "7d" ? "168h" : range === "30d" ? "720h" : "24h", limit: 50 }, signal), initialPageParam: undefined as string | undefined, getNextPageParam: (last, pages) => pages.length < 10 && last.hasMore ? last.nextPageCursor : undefined, enabled: !invalid, @@ -41,7 +41,7 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on const capped = (query.data?.pages.length ?? 0) >= 10 && query.data?.pages.at(-1)?.hasMore; return ({ tab: "Routes", route: pathKey, routeIata: iata, routeRange: range, routeSince: String(first.windowStart), routeUntil: String(first.windowEnd), hash: null, analyze: null, observation: null, path: null, observer: null, node: null })} />}>
- setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} /> + setParams(previous => { const next = new URLSearchParams(previous); next.set("routeRange", value); next.delete("routeSince"); next.delete("routeUntil"); return next; })} />

{t("observerCompare.retainedWindow")}

{fixed &&

{t("routeEvidence.shared")}

} {first &&

{new Date(first.windowStart).toISOString().slice(0, 19).replace("T", " ")} → {new Date(first.windowEnd).toISOString().slice(0, 19).replace("T", " ")} UTC

} diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 0f3a6b64..56158bae 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -17,7 +17,6 @@ function validation(value: Selection): string | null { if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { return "Choose a valid start and a later end time."; } - if (value.until - value.since > 3 * 86_400_000) return "observerCompare.windowLimit"; return null; } diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 7e4d198b..84fb5734 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -15,19 +15,18 @@ import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; -const RANGES: StatsRange[] = ["24h", "3d", "30d"]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); -const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "3d"); +const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); interface StatsOverviewProps { wsManager: WsManager; onObserverDashboard?: (id: string) => void; } -// Stats page shell: an analytics sub-header and range over the active -// sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the -// view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. +// Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab and range live in +// the URL (?statsTab/?range) so the view is shareable; replace:true keeps it out of history. export function StatsOverview({ wsManager, onObserverDashboard }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const tab = asTab(params.get("statsTab")); diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index c95357a1..083bfe32 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -90,7 +90,7 @@ const TAB_OPTIONS = [ { value: "graph", icon: }, ]; -const RANGES: StatsRange[] = ["24h", "3d", "30d"]; +const RANGES: StatsRange[] = ["24h", "7d", "30d"]; interface Props { tab: StatsTab; diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index c5ac8537..fe00fcfb 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -165,12 +165,11 @@ export interface ObserverActivity { } // Sub-tab + time-range identifiers shared across the Stats page. -export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph"; -export type StatsRange = "24h" | "3d" | "7d" | "30d"; +export type StatsTab = "mesh" | "traffic" | "signal" | "paths" | "scopes" | "talkers" | "clockdrift" | "compare" | "graph"; +export type StatsRange = "24h" | "7d" | "30d"; export const RANGE_MS: Record = { "24h": 24 * 60 * 60 * 1000, - "3d": 3 * 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000, }; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 383ee1ac..a65e309d 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -5,7 +5,6 @@ import type { StatsRange } from "./types"; // Go time.ParseDuration strings the telemetry endpoint expects, per selected range. const RANGE_PARAM: Record = { "24h": "24h", - "3d": "72h", "7d": "168h", "30d": "720h", }; @@ -14,7 +13,6 @@ const RANGE_PARAM: Record = { // charts don't drown in points. const INTERVAL_PARAM: Record = { "24h": "1h", - "3d": "1h", "7d": "6h", "30d": "24h", }; @@ -33,7 +31,6 @@ export function useObserver(observerId: string | null, poll = false) { // Bucket per range: a quiet hour stays visible at 24h, the longer windows stay under ~200 points. const ACTIVITY_INTERVAL: Record = { "24h": "15m", - "3d": "1h", "7d": "1h", "30d": "6h", }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 999ec3f3..4ae076e6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -66,10 +66,8 @@ "ranges": { "24h": "24h", "7d": "7d", - "30d": "30d", - "3d": "3d" - }, - "archiveHelp": "Hourly analytics keep summaries for up to 30 days. Packet details and overlap use retained raw records; older summary history accumulates after archiving is enabled." + "30d": "30d" + } }, "signal": { "title": "RF / Signal", @@ -243,12 +241,9 @@ "choose": "Choose an observer", "searchHelp": "Search by name to find observers outside the first 50 results.", "summaryMissing": "Packet summary unavailable on this server. Charts remain available.", - "trafficRecent": "Recent packet traffic", - "trafficQuiet": "No recent recorded packets", "statusRecent": "Recent status", "statusStale": "Status is stale", "statusMissing": "No status report", - "scopeNote": "History for this observer across all received regions.", "activity": "Recorded packets per {{interval}}", "mix": "Packet-type mix", "signal": "Received signal", @@ -270,15 +265,11 @@ "publicKey": "Public key", "copy": "Copy", "copied": "Copied", - "broker": "Broker", "presence": "Last contact", "packetArrival": "Packet arrival", "firstSeen": "First seen", "copyLink": "Copy observer link", - "updated": "Calculated", "window": "UTC window", - "windowHelp": "The latest bucket may be partial; gaps are missing records, not proof of an outage. Repeated deliveries of the same retained packet/observer pair count once.", - "exact": "Exact activity values", "time": "Time (UTC)", "mean": "Average", "minimum": "Minimum", @@ -288,7 +279,6 @@ "recording": "Loading observer…", "hour": "hour", "day": "day", - "trafficUnknown": "Packet freshness unavailable", "returnTo": "Back to {{page}}", "directory": "All observers", "sidebarSearch": "Search observers" @@ -316,8 +306,7 @@ "both": "Both", "onlyB": "Only B", "definition": "Each flood packet hash counts once across both observers. Repeat receptions are deduplicated. This is not a packet-loss measurement: expiry, offline devices and broker interruptions affect the available reports.", - "windowLimit": "Choose a period of 3 days or less for retained packet records.", - "retainedWindow": "Packet details and overlap use retained raw records, with a maximum 3-day window." + "retainedWindow": "Packet details and overlap use retained raw records, so periods older than the server's packet retention come back incomplete." }, "investigation": { "title": "Reception evidence", @@ -358,15 +347,11 @@ "fewer": "Show fewer paths", "nodeDialog": "Node detail", "packetDialog": "Packet analyzer", - "closeObserver": "Close observer details", - "observerList": "Inspect an observer" + "closeObserver": "Close observer details" }, "routeEvidence": { "title": "Retained reports", "window": "Time window", - "day": "24 hours", - "week": "7 days", - "month": "30 days", "shared": "Shared time window", "invalid": "Invalid shared time window. Choose a range to continue.", "loading": "Loading retained reports…", @@ -399,8 +384,6 @@ "keyMissing": "No decryption key is configured for this channel.", "scope": "Transport scope", "allScopes": "All scopes", - "aboutScopes": "About scope labels", - "scopeHelp": "Labels describe the first recorded packet, not the channel key. Other receptions may differ; inspect the packet for its reports. Only retained messages are shown.", "unscoped": "No transport scope", "unknownScope": "Unresolved scope", "unavailableScope": "Scope unavailable", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ec00f6da..35178e7e 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -67,10 +67,8 @@ "ranges": { "24h": "24 h", "7d": "7 j", - "30d": "30 j", - "3d": "3 j" - }, - "archiveHelp": "Les statistiques horaires conservent des résumés jusqu’à 30 jours. Les détails et les recoupements utilisent les paquets bruts conservés ; l’historique s’accumule après l’activation de l’archivage." + "30d": "30 j" + } }, "signal": { "title": "RF / Signal", @@ -247,12 +245,9 @@ "choose": "Choisir un observateur", "searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.", "summaryMissing": "Résumé des paquets indisponible sur ce serveur. Les graphiques restent disponibles.", - "trafficRecent": "Trafic de paquets récent", - "trafficQuiet": "Aucun paquet récent enregistré", "statusRecent": "État récent", "statusStale": "État périmé", "statusMissing": "Aucun rapport d’état", - "scopeNote": "Historique de cet observateur, toutes régions de réception confondues.", "activity": "Paquets enregistrés par {{interval}}", "mix": "Répartition des types", "signal": "Signal reçu", @@ -274,15 +269,11 @@ "publicKey": "Clé publique", "copy": "Copier", "copied": "Copié", - "broker": "Courtier", "presence": "Dernier contact", "packetArrival": "Réception de paquet", "firstSeen": "Première réception", "copyLink": "Copier le lien de l’observateur", - "updated": "Calculé", "window": "Période UTC", - "windowHelp": "La dernière période peut être partielle ; les lacunes sont des données manquantes, pas la preuve d’une panne. Les livraisons répétées du même couple paquet/observateur conservé ne comptent qu’une fois.", - "exact": "Valeurs exactes de l’activité", "time": "Heure (UTC)", "mean": "Moyenne", "minimum": "Minimum", @@ -292,7 +283,6 @@ "recording": "Chargement de l’observateur…", "hour": "heure", "day": "jour", - "trafficUnknown": "Fraîcheur des paquets inconnue", "returnTo": "Retour à « {{page}} »", "directory": "Tous les observateurs", "sidebarSearch": "Rechercher les observateurs" @@ -320,8 +310,7 @@ "both": "Les deux", "onlyB": "B seulement", "definition": "Chaque empreinte de paquet flood est comptée une seule fois pour les deux observateurs. Les réceptions répétées sont dédupliquées. Ce n’est pas une mesure de perte de paquets : l’expiration, les appareils hors ligne et les interruptions des courtiers affectent les rapports disponibles.", - "windowLimit": "Choisissez une période de 3 jours ou moins pour les paquets conservés.", - "retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés, sur une période maximale de 3 jours." + "retainedWindow": "Les détails et les recoupements utilisent les paquets bruts conservés : une période plus ancienne que la rétention du serveur sera incomplète." }, "investigation": { "title": "Rapports de réception", @@ -362,15 +351,11 @@ "fewer": "Afficher moins de trajets", "nodeDialog": "Détails du nœud", "packetDialog": "Analyseur de paquets", - "closeObserver": "Fermer les détails de l’observateur", - "observerList": "Examiner un observateur" + "closeObserver": "Fermer les détails de l’observateur" }, "routeEvidence": { "title": "Rapports conservés", "window": "Période", - "day": "24 heures", - "week": "7 jours", - "month": "30 jours", "shared": "Période partagée", "invalid": "Période partagée invalide. Choisissez une période pour continuer.", "loading": "Chargement des rapports conservés…", @@ -403,8 +388,6 @@ "keyMissing": "Aucune clé de déchiffrement n’est configurée pour ce canal.", "scope": "Portée de transport", "allScopes": "Toutes les portées", - "aboutScopes": "À propos des portées", - "scopeHelp": "Les étiquettes décrivent le premier paquet enregistré, pas la clé du canal. D’autres réceptions peuvent différer ; inspectez le paquet pour consulter ses observations. Seuls les messages conservés sont affichés.", "unscoped": "Sans portée de transport", "unknownScope": "Portée non résolue", "unavailableScope": "Portée indisponible", diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index ac0191db..cf258dee 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -81,7 +81,7 @@ describe("observer investigation return", () => { fireEvent.change(screen.getByLabelText("Route filter"), { target: { value: "roof" } }); screen.getByTestId("route-scroll").scrollTop = 180; click("Route observer"); click("Open dashboard"); - expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 3d"); + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d"); expect(window.location.search).toContain("tab=Observers&observer=o1"); expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route"); fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "24h" } }); diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx index 80b81420..99bc5f57 100644 --- a/tests/App.quickObserver.test.tsx +++ b/tests/App.quickObserver.test.tsx @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, render, screen } from "@testing-library/react"; import type { ReactNode } from "react"; import { App } from "../src/App"; @@ -16,7 +16,11 @@ vi.mock("../src/features/packets/PacketAnalyzerOverlay", () => ({ PacketAnalyzer vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onViewObserver }: { detail?: { packetHash: string }; onViewObserver: (id: string) => void }) => <>

{detail?.packetHash}

})); -beforeEach(() => { window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); }); +beforeEach(() => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); describe("quick observer investigation", () => { it("closes the quick observer before opening its advert analyzer", () => { diff --git a/tests/App.viewOnMap.test.tsx b/tests/App.viewOnMap.test.tsx new file mode 100644 index 00000000..12e90f3e --- /dev/null +++ b/tests/App.viewOnMap.test.tsx @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; + +// any on*/subscribe call the Packets tab makes gets a no-op unsubscribe +vi.mock("../src/api/ws-manager", () => ({ + WsManager: class { + constructor() { + return new Proxy(this, { get: (target, key) => (key in target ? target[key as keyof typeof target] : () => () => {}) }); + } + connect() {} + disconnect() {} + updateSubscription() {} + }, +})); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }), getScopes: async () => [] })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ + AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => ( + <> + + + {children} + + ), +})); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/map/MapView", () => ({ MapView: () =>

Map view

})); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ + NodeDetailPanel: ({ nodeId, onViewOnMap }: { nodeId: string; onViewOnMap?: (lat: number, lng: number) => void }) => ( +
+ {onViewOnMap && } +
+ ), +})); +vi.mock("../src/features/packets/usePacketDetail", () => ({ + usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }), +})); +vi.mock("../src/features/packets/PacketTable", () => ({ PacketTable: () => null })); +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ + PacketAnalyzerDrawer: ({ onViewNode }: { onViewNode: (id: string) => void }) => , +})); + +const params = () => new URLSearchParams(window.location.search); + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => {}, removeItem: () => {} }); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("View on map", () => { + it("frames the node on the Map tab and keeps its panel open there", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + expect(await screen.findByText("Map view")).toBeInTheDocument(); + expect(params().get("tab")).toBe("Map"); + expect(params().get("lat")).toBe("45.42153"); + expect(params().get("lng")).toBe("-75.69719"); + expect(params().get("zoom")).toBe("14"); + expect(screen.getByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + }); + + it("is not offered while the map is already showing", async () => { + window.history.replaceState({}, "", "/?tab=Map&node=node-a"); + render(); + await screen.findByText("Map view"); + expect(screen.getByRole("region", { name: "Node node-a" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("drops the one-off map framing when leaving the Map tab", async () => { + window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); + render(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + await screen.findByText("Map view"); + fireEvent.click(screen.getByText("Nodes tab")); + expect(params().get("tab")).toBe("Nodes"); + expect(params().has("lat")).toBe(false); + expect(params().has("lng")).toBe(false); + expect(params().has("zoom")).toBe(false); + }); + + it("closes stacked investigation panels and selects that node on the map", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=abcd&analyze=1"); + render(); + fireEvent.click(screen.getByText("Hop node")); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "View on map" })); + expect(await screen.findByText("Map view")).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("region", { name: "Node node-b" })).toBeInTheDocument(); + }); +}); diff --git a/tests/features/channels/MessagePanel.scope.test.tsx b/tests/features/channels/MessagePanel.scope.test.tsx index bed16379..947ed91b 100644 --- a/tests/features/channels/MessagePanel.scope.test.tsx +++ b/tests/features/channels/MessagePanel.scope.test.tsx @@ -153,29 +153,29 @@ describe("channel scope evidence", () => { ).toBeInTheDocument(); expect(screen.queryByText("No transport scope")).not.toBeInTheDocument(); }); -}); -it("preserves the newest/live page after more than twenty older pages", async () => { - vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { - const index = params?.cursor ?? 0; - return page( - [ - { - ...base, - id: 1000 - index, - packetHash: `history-${index}`, - content: `History ${index}`, - sentAt: 1000 - index, - }, - ], - index + 1, - ); + it("preserves the newest/live page after more than twenty older pages", async () => { + vi.mocked(getChannelMessagesPage).mockImplementation(async (_id, params) => { + const index = params?.cursor ?? 0; + return page( + [ + { + ...base, + id: 1000 - index, + packetHash: `history-${index}`, + content: `History ${index}`, + sentAt: 1000 - index, + }, + ], + index + 1, + ); + }); + show(); + await screen.findByText("History 0"); + for (let index = 1; index <= 21; index++) { + fireEvent.click(screen.getByRole("button", { name: /Load older/ })); + await screen.findByText(`History ${index}`); + } + expect(screen.getByText("History 0")).toBeInTheDocument(); }); - show(); - await screen.findByText("History 0"); - for (let index = 1; index <= 21; index++) { - fireEvent.click(screen.getByRole("button", { name: /Load older/ })); - await screen.findByText(`History ${index}`); - } - expect(screen.getByText("History 0")).toBeInTheDocument(); }); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index c71685c1..f95a0235 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -125,3 +125,35 @@ describe("NodeDetailPanel clock drift", () => { expect(screen.queryByText(/Clock drift/i)).not.toBeInTheDocument(); }); }); + +describe("NodeDetailPanel View on map", () => { + function renderWithMap(onViewOnMap?: (lat: number, lng: number) => void) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); + } + + it("sends the node's coordinates", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + const onViewOnMap = vi.fn(); + renderWithMap(onViewOnMap); + fireEvent.click(await screen.findByRole("button", { name: "View on map" })); + expect(onViewOnMap).toHaveBeenCalledWith(45.42153, -75.69719); + }); + + it("is not offered for a node without a location", async () => { + renderWithMap(vi.fn()); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("is not offered when the caller has no map to open", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + renderWithMap(); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx index 119445d4..72b64445 100644 --- a/tests/features/observers/ObserverComparison.test.tsx +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -11,7 +11,7 @@ const A = "11111111-1111-1111-1111-111111111111"; const B = "22222222-2222-2222-2222-222222222222"; const until = Math.floor(Date.now() / 3_600_000) * 3_600_000; const observer = { id: A, displayName: "Roof A", brokers: [], iata: "YOW" } as unknown as Observer; -const activity: ObserverActivity = { range: "72h", interval: "1h", windowStart: until - 259_200_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; +const activity: ObserverActivity = { range: "168h", interval: "1h", windowStart: until - 604_800_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: unknown }) => {JSON.stringify(option)} })); vi.mock("../../../src/api/client", async original => ({ @@ -27,7 +27,7 @@ beforeEach(() => { }); function view(id = B, data = activity, anchor: number | null = until) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const props = { observerA: observer, activityA: data, range: "3d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const props = { observerA: observer, activityA: data, range: "7d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; const wrapper = ({ children }: { children: React.ReactNode }) => {children}; return { ...render(, { wrapper }), props }; } @@ -35,7 +35,7 @@ it("aligns activity and retained overlap to the primary observer's effective win view(); expect(await screen.findByText("23")).toBeInTheDocument(); expect(screen.getByText("19")).toBeInTheDocument(); - expect(getObserverActivity).toHaveBeenCalledWith(B, "72h", "1h", until); + expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until); expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal)); expect(screen.getByText("Retained flood-packet overlap")).toBeInTheDocument(); expect(screen.getByText(/Raw packet history may expire/)).toBeInTheDocument(); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index d53089ef..d2a0496a 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -65,7 +65,7 @@ describe("Observer destination", () => { view(); fireEvent.change(screen.getByLabelText("Directory search"), { target: { value: "roof" } }); fireEvent.click(screen.getByText("Open A")); - expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 3d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d"); expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); expect(screen.getByRole("status").textContent).toContain("iata=YOW"); fireEvent.click(screen.getByText("Browser back")); @@ -76,11 +76,16 @@ describe("Observer destination", () => { }); it("restores a deep link and has a directory return without prior history", async () => { view("?tab=Observers&observer=observer-b&range=30d"); - expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 3d"); - expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "3d"]); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); + expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "7d", "30d"]); fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); expect(screen.getByLabelText("Directory search")).toBeVisible(); }); + it("keeps the directory return on desktop, where the sidebar can't deselect", async () => { + view("?tab=Observers&observer=observer-a&range=7d"); + await screen.findByRole("heading"); + expect(screen.getByRole("button", { name: /Back to observers/ }).className).not.toMatch(/\bmd:hidden\b/); + }); }); it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => { @@ -106,6 +111,10 @@ it("rejects duplicate, malformed and future comparison anchors", async () => { const viewResult = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=bad"); expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); viewResult.unmount(); - view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + const future = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + future.unmount(); + const hour = Math.floor(Date.now() / 3_600_000) * 3_600_000 - 3_600_000; + view(`?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=${hour}&compareUntil=${hour}`); expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); }); diff --git a/tests/features/observers/ObserverTable.test.tsx b/tests/features/observers/ObserverTable.test.tsx new file mode 100644 index 00000000..7dab0ee4 --- /dev/null +++ b/tests/features/observers/ObserverTable.test.tsx @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import "../../../src/i18n"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { ObserverTable } from "../../../src/features/observers/ObserverTable"; +import { getObserversPage } from "../../../src/api/client"; +import type { ObserverSummary } from "../../../src/features/observers/types"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }) })); +vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/hooks/useWsHandlers", () => ({ useWsObserverStatusHandler: () => {} })); +vi.mock("../../../src/api/client", () => ({ + getObserversPage: vi.fn(), + getBrokers: vi.fn(async () => []), +})); + +const online = { id: "a", displayName: "Rooftop", iata: "YOW", status: "online" } as ObserverSummary; +const offline = { id: "b", displayName: "Hilltop", iata: "YOW", status: "offline" } as ObserverSummary; + +function table(compact: boolean) { + return {}} />; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getObserversPage).mockImplementation(async (_iatas, opts) => ({ + items: opts?.status === "offline" ? [offline] : [online, offline], + hasMore: false, + nextCursor: null, + })); +}); + +describe("compact observer sidebar", () => { + it("does not apply directory filters it has no controls for", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const { rerender } = render({table(false)}); + fireEvent.click(screen.getByRole("button", { name: /Status/ })); + fireEvent.click(screen.getByText("Offline")); + await waitFor(() => expect(screen.queryByText("Rooftop")).not.toBeInTheDocument()); + + rerender({table(true)}); + expect(await screen.findByText("Rooftop")).toBeInTheDocument(); + expect(screen.getByText("Hilltop")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts index 691cb03b..de849cb7 100644 --- a/tests/features/observers/observer-navigation.test.ts +++ b/tests/features/observers/observer-navigation.test.ts @@ -1,10 +1,36 @@ -import { expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { observerDestination, observerRange } from "../../../src/features/observers/observer-navigation"; -it("normalizes legacy analytics links without losing region",()=>{ - const p=observerDestination(new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"),"abc"); - expect(p.get("tab")).toBe("Observers");expect(p.get("observer")).toBe("abc");expect(p.get("range")).toBe("3d");expect(p.get("iata")).toBe("YOW");expect(p.has("observerId")).toBe(false);expect(p.has("statsTab")).toBe(false); + +describe("observerDestination", () => { + it("normalizes legacy analytics links without losing region or range", () => { + const p = observerDestination( + new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"), + "abc", + ); + expect(p.get("tab")).toBe("Observers"); + expect(p.get("observer")).toBe("abc"); + expect(p.get("range")).toBe("30d"); + expect(p.get("iata")).toBe("YOW"); + expect(p.has("observerId")).toBe(false); + expect(p.has("statsTab")).toBe(false); + }); + + it("drops unrelated investigation selectors and defaults the range to 7d", () => { + const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"), "b"); + expect(p.toString()).toBe("tab=Observers&observer=b&range=7d"); + }); }); -it("drops unrelated investigation selectors and bounds range",()=>{ - const p=observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"),"b"); - expect(p.toString()).toBe("tab=Observers&observer=b&range=3d");expect(observerRange("invalid")).toBe("3d"); + +describe("observerRange", () => { + it("accepts the ranges the server can serve", () => { + expect(observerRange("24h")).toBe("24h"); + expect(observerRange("7d")).toBe("7d"); + expect(observerRange("30d")).toBe("30d"); + }); + + it("falls back to 7d for unknown or retired values", () => { + expect(observerRange("invalid")).toBe("7d"); + expect(observerRange("3d")).toBe("7d"); + expect(observerRange(null)).toBe("7d"); + }); }); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 1c75deda..09ad7c5f 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -102,7 +102,7 @@ describe("PacketEndpoints", () => { const tip = screen.getByRole("tooltip"); expect(within(tip).getByText("Alpha")).toBeInTheDocument(); expect(within(tip).getByText("Beta")).toBeInTheDocument(); - expect(trigger).toHaveAttribute("title", "Alpha, Beta"); + expect(screen.getByText("Alpha +1")).toHaveAttribute("title", "Alpha, Beta"); expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); }); @@ -111,7 +111,7 @@ describe("PacketEndpoints", () => { expect(screen.getAllByRole("button")).toHaveLength(1); const chip = screen.getByText("Alpha +1").parentElement!; expect(chip).not.toHaveAttribute("tabindex"); - expect(chip).toHaveAttribute("title", "Alpha, Beta"); + expect(screen.getByText("Alpha +1")).toHaveAttribute("title", "Alpha, Beta"); }); it("opens all candidates on touch without activating the packet row", () => { diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 079e5cd0..4aab86c7 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -123,12 +123,12 @@ describe("PacketTableRow", () => { render( {}} />); expect(screen.getByText("Unknown")).toBeInTheDocument(); }); -}); -it("keeps long route labels inside their dedicated track beside a scope", () => { - render( {}} />); - const route = screen.getByText("TRANSPORT_FLOOD"); - expect(route).toHaveClass("truncate"); - expect(route.parentElement).toHaveClass("min-w-0"); - expect(screen.getByText("#ykf")).toBeInTheDocument(); + it("keeps long route labels inside their dedicated track beside a scope", () => { + render( {}} />); + const route = screen.getByText("TRANSPORT_FLOOD"); + expect(route).toHaveClass("truncate"); + expect(route.parentElement).toHaveClass("min-w-0"); + expect(screen.getByText("#ykf")).toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PathData.test.tsx b/tests/features/packets/PathData.test.tsx index 283c617b..7ddc96cd 100644 --- a/tests/features/packets/PathData.test.tsx +++ b/tests/features/packets/PathData.test.tsx @@ -42,6 +42,13 @@ describe("ResolvedHopBlock per-hop SNR", () => { }); }); +describe("ResolvedHopBlock tooltip", () => { + it("relies on its popover rather than a native title", () => { + const { container } = render(); + expect(container.querySelector("[title]")).toBeNull(); + }); +}); + describe("ResolvedHopBlock (desktop)", () => { it("opens the node directly when the single-match block is clicked", () => { setMobile(false); diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteEvidencePanel.test.tsx index 511558a7..94bd7372 100644 --- a/tests/features/routes/RouteEvidencePanel.test.tsx +++ b/tests/features/routes/RouteEvidencePanel.test.tsx @@ -26,16 +26,26 @@ function mount(url = "/?tab=Routes") { } beforeEach(() => { vi.clearAllMocks(); vi.mocked(getRouteEvidence).mockResolvedValue(page); }); describe("retained route evidence", () => { - it("normalizes an old month link to 72 hours and offers only retained periods", async () => { + it("keeps a shared month link and offers every period the server serves", async () => { mount("/?routeRange=30d"); await screen.findByText("Garden"); - expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything()); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.queryByRole("button", { name: "7 days" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: "30 days" })).not.toBeInTheDocument(); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "720h", limit: 50 }, expect.anything()); + expect(screen.getByRole("button", { name: "30d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "24h" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "7d" })).toBeInTheDocument(); }); - it("does not request a shared raw-evidence period longer than three days", async () => { - mount("/?routeSince=1700000000000&routeUntil=1700345600000"); + it("falls back to 24h for a retired 3d link", async () => { + mount("/?routeRange=3d"); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { range: "24h", limit: 50 }, expect.anything()); + }); + it("requests a shared five-day window", async () => { + mount("/?routeSince=1700000000000&routeUntil=1700432000000"); + await screen.findByText("Garden"); + expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700432000000, limit: 50 }, expect.anything()); + }); + it("does not request a shared period longer than thirty days", async () => { + mount("/?routeSince=1700000000000&routeUntil=1702678400001"); expect(await screen.findByRole("alert")).toBeInTheDocument(); expect(getRouteEvidence).not.toHaveBeenCalled(); }); @@ -64,9 +74,9 @@ describe("retained route evidence", () => { await screen.findByText("Garden"); expect(getRouteEvidence).toHaveBeenCalledWith("YOW", key, { since: 1700000000000, until: 1700086400000, limit: 50 }, expect.anything()); expect(screen.getByText("Shared time window")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "3d" })); - await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "72h", limit: 50 }, expect.anything())); - expect(screen.getByText(/routeRange=3d/)).not.toHaveTextContent("routeSince"); + fireEvent.click(screen.getByRole("button", { name: "7d" })); + await waitFor(() => expect(getRouteEvidence).toHaveBeenLastCalledWith("YOW", key, { range: "168h", limit: 50 }, expect.anything())); + expect(screen.getByText(/routeRange=7d/)).not.toHaveTextContent("routeSince"); }); it("does not replace an invalid shared window with an unrelated default", async () => { mount("/?routeSince=bad"); diff --git a/tests/features/stats/CompareObserversTab.test.tsx b/tests/features/stats/CompareObserversTab.test.tsx index c7e29aaa..7b0483b2 100644 --- a/tests/features/stats/CompareObserversTab.test.tsx +++ b/tests/features/stats/CompareObserversTab.test.tsx @@ -35,7 +35,7 @@ beforeEach(() => { }); describe("observer comparison", () => { - it("rejects a raw packet comparison beyond three days", async () => { + it("accepts a comparison longer than three days", async () => { mount(); await screen.findAllByRole("option", { name: /Rooftop/ }); fireEvent.change(screen.getByLabelText("Observer A"), { target: { value: a } }); @@ -43,8 +43,8 @@ describe("observer comparison", () => { fireEvent.change(screen.getByLabelText("Start (local time)"), { target: { value: "2026-01-01T00:00" } }); fireEvent.change(screen.getByLabelText("End (local time)"), { target: { value: "2026-01-05T00:00" } }); fireEvent.click(screen.getByRole("button", { name: "Compare" })); - expect(await screen.findByRole("alert")).toHaveTextContent("3 days or less"); - expect(getObserverComparison).not.toHaveBeenCalled(); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalled()); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); }); it("waits for Compare, sends explicit dates and region, and puts the selection in the URL", async () => { mount(); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index 7006f09f..2df538b5 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -204,30 +204,30 @@ describe("Observer dashboard hierarchy", () => { expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument(); expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); }); -}); -it("keeps packet metrics without the removed traffic text badge", () => { - const now = Date.now(); - observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; - activityResult.data = { - ...activity, - summary: { - recordedPackets: 9, - lastCompleteHour: 2, - lastCompleteHourStart: now - 7200000, - lastCompleteHourEnd: now - 3600000, - latestRecordedAt: now - 3600000, - }, - }; - renderTab(); - expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); - expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); -}); + it("keeps packet metrics without the removed traffic text badge", () => { + const now = Date.now(); + observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; + activityResult.data = { + ...activity, + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: now - 7200000, + lastCompleteHourEnd: now - 3600000, + latestRecordedAt: now - 3600000, + }, + }; + renderTab(); + expect(screen.queryByText("Recent packet traffic")).not.toBeInTheDocument(); + expect(within(screen.getByRole("list", { name: "Observer metrics" })).getByText("9")).toBeInTheDocument(); + }); -it("omits the duplicate observer picker and marked explanatory sections", () => { - renderTab(); - expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); - expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); - expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); - expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); + it("omits the duplicate observer picker and marked explanatory sections", () => { + renderTab(); + expect(screen.queryByRole("searchbox", { name: "Find an observer" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Choose an observer" })).not.toBeInTheDocument(); + expect(screen.queryByText("Exact activity values")).not.toBeInTheDocument(); + expect(screen.queryByText(/History for this observer across all received regions/)).not.toBeInTheDocument(); + }); }); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx index 52e3d8bc..c7940938 100644 --- a/tests/features/stats/StatsSubHeader.test.tsx +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -8,27 +8,27 @@ afterEach(() => vi.restoreAllMocks()); describe("translated analytics controls", () => { it("keeps archived analytics ranges without the legacy Observer destination", async () => { const props = { onTabChange: vi.fn(), onRangeChange: vi.fn() }; - const { rerender } = render(); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); + const { rerender } = render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Observer", exact: true })).not.toBeInTheDocument(); await act(() => i18n.changeLanguage("fr")); - fireEvent.click(screen.getByRole("button", { name: "3 j" })); - expect(props.onRangeChange).toHaveBeenCalledWith("3d"); - rerender(); - expect(screen.queryByRole("button", { name: "7 j" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "7 j" })); + expect(props.onRangeChange).toHaveBeenCalledWith("7d"); + rerender(); + expect(screen.queryByRole("button", { name: "3 j" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); }); it("keeps section and range identifiers when labels change", async () => { const onTabChange = vi.fn(), onRangeChange = vi.fn(); - render(); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + render(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); await act(() => i18n.changeLanguage("fr")); expect(screen.getByRole("group", { name: "Section des statistiques" })).toBeInTheDocument(); const range = screen.getByRole("group", { name: "Période" }); - expect(within(range).getByRole("button", { name: "3 j" })).toHaveAttribute("aria-pressed", "true"); + expect(within(range).getByRole("button", { name: "7 j" })).toHaveAttribute("aria-pressed", "true"); expect(onTabChange).not.toHaveBeenCalled(); expect(onRangeChange).not.toHaveBeenCalled(); fireEvent.click(screen.getByRole("button", { name: "Chemins et hachages" })); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index 4b65d4d9..65b8043f 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -11,11 +11,14 @@ vi.mock("../../../src/features/stats/MeshTab", () => ({ MeshTab: () =>

Mesh c vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Scope charts

})); function Location() { return {useLocation().search}; } -it("normalizes old seven-day links without the removed retention banner", () => { - render(); - expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.queryByRole("button", { name: "7d" })).not.toBeInTheDocument(); +it("keeps seven-day links and falls back from retired 3d links", () => { + const { unmount } = render(); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); + unmount(); + render(); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); expect(screen.queryByText(/Hourly analytics keep summaries for up to 30 days/)).not.toBeInTheDocument(); }); @@ -33,8 +36,8 @@ it("opens Signal from a shared URL and retains regional state when changing rang render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); - fireEvent.click(screen.getByRole("button", { name: "3d" })); - expect(screen.getByText("Signal range 3d")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "7d" })); + expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); }); diff --git a/tests/features/stats/useTelemetry.test.ts b/tests/features/stats/useTelemetry.test.ts index d1c7016e..9dd576f4 100644 --- a/tests/features/stats/useTelemetry.test.ts +++ b/tests/features/stats/useTelemetry.test.ts @@ -5,7 +5,6 @@ import { ApiError } from "../../../src/api/client"; describe("activityParamsFor", () => { it("pairs each stats range with the bucket the charts are sized for", () => { expect(activityParamsFor("24h")).toEqual({ range: "24h", interval: "15m" }); - expect(activityParamsFor("3d")).toEqual({ range: "72h", interval: "1h" }); expect(activityParamsFor("7d")).toEqual({ range: "168h", interval: "1h" }); expect(activityParamsFor("30d")).toEqual({ range: "720h", interval: "6h" }); }); From 3e43cfd0092d7c51d866586d6603337f6fef3e1e Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Wed, 30 Sep 2026 10:19:32 -0400 Subject: [PATCH 118/163] fix: follow up on last week's merges (#55-#101) (#102) Bugs: clear compare params on tab change, recover channel live messages after a 429, observer copy link keeps only region params, route table no longer navigates on region change with nothing selected, map borders memo on data identity, live overview can't resurrect a failed query, French relative times, no language write on first visit, 0/0 node locations hidden outside the map, path/signal stats use the shared retry rule. i18n: node/packet/mesh/observer strings that were still hardcoded after #62. Cleanup: formatUtc, ACTION_BUTTON_CLASS, observer-stats, useWindowedStats, observer-id; observer pages use StatCard/Card/Field/Segmented/SearchBar/SelectDropdown; one details chrome on the stats tabs; comment and translations-guide trims; localStorage stub for Node 26 in the test setup. --- docs/translations.md | 75 +++++------------- src/App.tsx | 6 +- src/api/client.ts | 2 +- src/components/ModalOverlay.tsx | 10 +-- src/components/SearchBar.tsx | 2 +- src/components/Timestamp.tsx | 5 +- src/components/action-button.ts | 2 + src/features/channels/ChannelList.tsx | 22 ++++-- src/features/map/location.ts | 4 +- src/features/map/packet-flow.ts | 8 +- src/features/map/useMapBordersData.ts | 13 +++- src/features/nodes/NodeDetailPanel.tsx | 15 ++-- src/features/nodes/NodeTable.tsx | 36 +++++---- src/features/observers/ObserverComparison.tsx | 20 ++--- .../observers/ObserverDetailPanel.tsx | 19 +---- src/features/observers/ObserverPage.tsx | 16 ++-- src/features/observers/ObserverPicker.tsx | 26 ++++--- src/features/observers/ObserverSummary.tsx | 44 ++++++----- src/features/observers/ObserverTable.tsx | 2 +- src/features/observers/observer-id.ts | 2 + src/features/observers/observer-navigation.ts | 2 +- src/features/observers/observer-stats.ts | 22 ++++++ src/features/packets/PacketAnalyzerDrawer.tsx | 7 +- src/features/packets/PacketEndpoints.tsx | 4 +- src/features/packets/PacketInvestigation.tsx | 19 ++--- src/features/packets/PacketTableRow.tsx | 3 +- src/features/packets/PathData.tsx | 4 +- src/features/routes/RouteEvidencePanel.tsx | 18 ++--- src/features/routes/RouteTable.tsx | 4 +- src/features/stats/CompareObserversTab.tsx | 6 +- src/features/stats/MeshTab.tsx | 42 +++++----- src/features/stats/ObserverTab.tsx | 11 +-- src/features/stats/PathsTab.tsx | 12 ++- src/features/stats/SignalTab.tsx | 15 ++-- src/features/stats/TalkersTab.tsx | 4 +- src/features/stats/useLiveStats.ts | 2 + src/features/stats/usePathStats.ts | 21 +---- src/features/stats/useSignalStats.ts | 21 +---- src/features/stats/useStats.ts | 2 +- src/features/stats/useWindowedStats.ts | 26 +++++++ src/i18n/index.ts | 2 + src/i18n/locales/en.json | 37 ++++++++- src/i18n/locales/fr.json | 39 +++++++++- src/lib/formatters.ts | 27 +++++-- src/types/api.ts | 2 +- tests/App.observerInvestigation.test.tsx | 30 +++++++- tests/components/Timestamp.test.tsx | 25 +++--- tests/features/channels/ChannelList.test.tsx | 57 +++++++++++++- .../map/useMapBordersData.refresh.test.tsx | 17 +++++ tests/features/nodes/NodeDetailPanel.test.tsx | 38 +++++++++- tests/features/nodes/NodeTable.test.tsx | 76 +++++++++++++++++++ .../observers/ObserverDetailPanel.test.tsx | 30 +++++++- .../features/observers/ObserverPage.test.tsx | 7 +- .../observers/ObserverPicker.test.tsx | 40 ++++++++++ .../ObserverSummary.compact.test.tsx | 21 +++++ tests/features/observers/observer-id.test.ts | 14 ++++ .../observers/observer-navigation.test.ts | 7 ++ .../features/observers/observer-stats.test.ts | 31 ++++++++ .../features/packets/PacketEndpoints.test.tsx | 16 +++- tests/features/packets/PathData.test.tsx | 23 +++++- .../routes/RouteEvidencePanel.test.tsx | 6 ++ tests/features/routes/RouteTable.test.tsx | 42 +++++++++- tests/features/stats/MeshTab.test.tsx | 16 ++++ tests/features/stats/useLiveStats.test.tsx | 19 +++++ tests/i18n/index.test.ts | 58 ++++++++++++-- tests/lib/formatters.test.ts | 36 +++++++++ tests/setup.ts | 18 +++++ 67 files changed, 988 insertions(+), 320 deletions(-) create mode 100644 src/components/action-button.ts create mode 100644 src/features/observers/observer-id.ts create mode 100644 src/features/observers/observer-stats.ts create mode 100644 src/features/stats/useWindowedStats.ts create mode 100644 tests/features/nodes/NodeTable.test.tsx create mode 100644 tests/features/observers/ObserverPicker.test.tsx create mode 100644 tests/features/observers/observer-id.test.ts create mode 100644 tests/features/observers/observer-stats.test.ts diff --git a/docs/translations.md b/docs/translations.md index b747d283..5736818d 100644 --- a/docs/translations.md +++ b/docs/translations.md @@ -1,24 +1,7 @@ # Translating Beacon The header language picker starts in English and saves the selected language in -this browser. Its trigger and panel use the same dropdown styling as the region -control. Open it with Enter/Space, Tab to a language and activate that button; -Escape closes it and returns focus to the trigger. French is the first -additional language. Current coverage includes desktop/mobile navigation, -region/theme controls, connection/retry labels, lazy-page loading, shared -analytics section/range controls and chart states, and Traffic, RF / Signal, Paths & Hashes, Scopes and Clock Drift -(headings, legends, descriptive chart labels, explanations and exact tables). -Shared Timestamp labels and their relative tooltips also follow the selected -language. Packet reception-evidence controls and path-map controls explain retained -reports, intended TRACE routes, unavailable selections and ambiguous/missing map -locations in both languages. Packet bytes, identifiers and routing values stay unchanged. Other feature pages and general dialogs remain follow-up work. Measurement units -and existing UTC/date/number formatting, including automatic chart time labels, -are unchanged; selecting French does not mean every screen is translated yet. - -The channel message panel also translates scope labels, evidence help, retained-history/key -availability states, pagination and packet-inspection actions. Scope names, hashes and message -bodies remain unchanged. The channel directory and its existing search controls are separate -translation follow-ups. +this browser; an unset or empty string in a catalog falls back to English. ## Add a language @@ -46,42 +29,22 @@ function LoadingMessage() { ``` Add the English key/value and translate it in other catalogs where possible. -Use `t("region.count", { count })` with i18next's language-specific plural -suffixes (`_one`, `_other`, and `_many` where applicable). Preserve placeholders -such as `{{seconds}}`; translate the whole phrase rather than joining words. -React escapes interpolated text; do not use `dangerouslySetInnerHTML` for it. - -Translate display labels, never the tab IDs (`Packets`, `Analytics`, etc.), URL -parameters, region slugs/IATA codes, query keys or WebSocket subscriptions. -Names and packet contents come from the network and remain unchanged. This slice -does not change date/time/number formatting or measurement units. HTML language -and direction follow the selected catalog; a future right-to-left translation -also needs layout review before being offered to users. - -Traffic, signal, path and scope chart helpers receive `t` explicitly, and their memoized options depend on -`t` so labels redraw when the language changes. Keep numerical series, null gaps, -half-open bin bounds and query keys unchanged. Use the real catalogs in tests; -check that a language-only change reuses the same cached request. Traffic keeps -IATA/model identifiers unchanged and translates only display labels for grouped -and unassigned areas; its heatmap tooltip uses the raw count for plural selection -and the existing formatted value for display. Scope names and search values stay -unchanged; only the chart remainder label and interface text are translated. -Clock Drift also supplies translated direction labels to the shared formatter; -its signs, magnitude, units and rounding remain unchanged. Shared Timestamp uses -the whole `timestamp.ago` phrase with `{{duration}}`, so French can put "il y a" -before the duration. Compact `s/m/h/d` units, flooring, future-time clamping, -local absolute timestamps and optional milliseconds are unchanged. Its existing -shared ticker keeps relative text fresh in either language. Per-page relative -phrases that bypass Timestamp and broader date/number formatting remain follow-ups. - -For translated sortable tables, give each `Column` a stable `id` and use -`defaultSort={{ id: "drift", direction: "desc" }}` (for example). The visible -`header` can then change language without losing sorting or focus. Existing -header-based callers remain supported. -Run `npm run build`, `npm run lint` and `npm test`. Test saved/unknown language -preferences, English fallback and switching without losing the selected view. -Check the picker and menus at narrow widths, including translated status text. -The language preference tolerates unavailable browser storage; the choice then -lasts only for that visit. Tests use the real i18next resources, reset to English -after each test, and keep route/state assertions on canonical identifiers. +- Use `t("region.count", { count })` with i18next's language-specific plural + suffixes (`_one`, `_other`, and `_many` where applicable). +- Preserve placeholders such as `{{seconds}}`; translate the whole phrase rather + than joining words. +- Never translate tab IDs, URL parameters, IATA codes, query keys, or packet + contents. +- Chart helpers receive `t` explicitly, and their memoized options must depend + on `t` so labels redraw when the language changes. +- Sortable tables need a stable `Column.id` (e.g. `defaultSort={{ id: "drift", + direction: "desc" }}`) so the visible `header` can change language without + losing sorting or focus. +- Shared `Timestamp` uses the whole `timestamp.ago` phrase with `{{duration}}`, + so a translation can reorder duration and phrase (e.g. French's "il y a"). + +## Verify + +Run `npm run build`, `npm run lint` and `npm test`. Tests use the real i18next +catalogs and reset to English after each test. diff --git a/src/App.tsx b/src/App.tsx index 5f87031d..02c5975d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -239,7 +239,11 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); - if (tab !== "Observers") next.delete("observer"); + // AppShell fires onTabChange even for a no-op click on the already-active tab — only an actual + // tab switch should drop the other tab's compare state (compareUntil is shared by both lists). + const changed = prev.get("tab") !== tab; + if (changed && tab !== "Observers") for (const key of ["observer", "compareWith", "compareUntil"]) next.delete(key); + if (changed && tab !== "Analytics") for (const key of ["compareA", "compareB", "compareSince", "compareUntil"]) next.delete(key); // lat/lng/zoom only seed the map on mount, so they'd re-frame it on every later visit if (tab !== "Map" || mapFocus) for (const key of ["lat", "lng", "zoom"]) next.delete(key); if (mapFocus) { diff --git a/src/api/client.ts b/src/api/client.ts index 4990248c..bfdd2fed 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -233,7 +233,7 @@ export function getTraceDetail(tag: string): Promise { export async function getObserver(observerId: string): Promise { const observer = await request(`/observers/${observerId}`); - // Older servers marshal the stored JSON byte slice as base64 instead of an object. + // The server marshals the stored JSON byte slice as base64; decode it, and accept an object once json.RawMessage ships. let metadata: unknown = observer.statusMetadata; if (typeof metadata === "string") { try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } diff --git a/src/components/ModalOverlay.tsx b/src/components/ModalOverlay.tsx index 94882eef..3363eaed 100644 --- a/src/components/ModalOverlay.tsx +++ b/src/components/ModalOverlay.tsx @@ -1,12 +1,10 @@ import { useRef, type ReactNode } from "react"; import { useFocusTrap } from "../hooks/useFocusTrap"; -// Right-anchored modal: dims the surface, focuses and traps keyboard focus within the panel, and -// closes on a backdrop click — only when the press started there, so releasing a text selection over -// the backdrop doesn't close. Escape closes only this active layer and stops before parent handlers. -// Pass `inactive` when another overlay is stacked on top so this one steps out of the modal/a11y -// path — it stops being an active modal and is hidden from assistive tech, while staying mounted so -// focus can return into it when the overlay above closes. +// Backdrop close fires only when the press started on the backdrop, so releasing a text +// selection there doesn't close it. Escape stops at this layer. +// `inactive` steps a stacked-under overlay out of the modal/a11y path while keeping it +// mounted, so focus can return to it when the overlay above closes. export function ModalOverlay({ label, onClose, inactive = false, children }: { label: string; onClose: () => void; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx index 9190fdd7..8148ee4c 100644 --- a/src/components/SearchBar.tsx +++ b/src/components/SearchBar.tsx @@ -113,7 +113,7 @@ export function SearchBar({ value, onChange, fields, field, onFieldChange, hideF aria-label={inputLabel} onChange={(e) => handleChange(e.target.value)} placeholder={inputLabel ?? `Search by ${(currentField?.label ?? "").toLowerCase()}...`} - className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "min-h-11 rounded-sm text-base md:text-sm" : "rounded-r-sm text-[11px]"}`} + className={`w-full font-mono bg-bg-surface border border-border pl-7 pr-7 py-1 text-text-bright placeholder:text-text-dim transition-colors ${hideField ? "rounded-sm text-[11px]" : "rounded-r-sm text-[11px]"}`} /> {localValue && ( )}
diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index aa7cc169..19eb6327 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -1,4 +1,5 @@ import { useState, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getNodesPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; @@ -7,7 +8,8 @@ import { useTick } from "../../hooks/useTick"; import { useInfinitePages } from "../../hooks/useInfinitePages"; import { patchInfinitePages } from "../../lib/infinite-pages"; import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers"; -import { formatHex, timeAgoMs, formatRadio } from "../../lib/formatters"; +import { formatHex, timeAgoParts, formatRadio } from "../../lib/formatters"; +import { hasMapLocation } from "../map/location"; import { Badge } from "../../components/Badge"; import { Tooltip } from "../../components/Tooltip"; import { ObserverIcon } from "../../components/ObserverIcon"; @@ -17,13 +19,26 @@ import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar"; import { nodeSearchParams } from "./node-search"; import { patchNodeSummary } from "./node-updates"; import { ForeignNodeBadge } from "./ForeignNodeBadge"; -import type { NodeSummary } from "./types"; +import type { NodeSummary, NodeIATA } from "./types"; import type { CursorPage } from "../../types/api"; import type { WsManager } from "../../api/ws-manager"; import type { WsNodeUpdate } from "../../types/ws"; const nodeId = (n: NodeSummary) => n.id; // stable id accessor for the paged hook's dedup +// A column cell and renderNodeCard sit outside any component, so the tooltip's translated +// "last heard" label needs its own tiny component to call useTranslation. +function IataBadge({ entry }: { entry: NodeIATA }) { + const { t } = useTranslation(); + const { count, unit } = timeAgoParts(entry.lastHeard); + const ago = t("timestamp.ago", { duration: t(`timestamp.unit.${unit}`, { count }) }); + return ( + + {entry.iata} + + ); +} + interface NodeTableProps { wsManager: WsManager; // shared with the Map tab (lifted to AppInner) so the detail panel persists across tab switches @@ -68,9 +83,7 @@ const COLUMNS: Column[] = [ node.iatas && node.iatas.length > 0 ? (
{node.iatas.map((entry) => ( - - {entry.iata} - + ))}
) : ( @@ -87,17 +100,16 @@ const COLUMNS: Column[] = [ header: "Location", className: "text-text-muted", cell: (node) => - node.lat != null && node.lng != null + hasMapLocation(node) ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` : "—", }, ]; function renderNodeCard(node: NodeSummary) { - const location = - node.lat != null && node.lng != null - ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` - : null; + const location = hasMapLocation(node) + ? `${node.lat.toFixed(2)}, ${node.lng.toFixed(2)}` + : null; return (
@@ -122,9 +134,7 @@ function renderNodeCard(node: NodeSummary) { {node.iatas && node.iatas.length > 0 && (
{node.iatas.map((entry) => ( - - {entry.iata} - + ))}
)} diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index 7d27770c..bbfe6100 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -2,8 +2,11 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getObserverComparison } from "../../api/client"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { formatBattery, formatUptime } from "../../lib/formatters"; import { ObserverPicker } from "./ObserverPicker"; +import { observerNoiseFloor } from "./observer-stats"; +import { OBSERVER_UUID } from "./observer-id"; import { Card, ChartCard } from "../stats/cards"; import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry"; import { useChartColors } from "../stats/chartTheme"; @@ -12,10 +15,9 @@ import { fillActivity, intervalToMs } from "../stats/transforms"; import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types"; import type { Observer } from "./types"; -const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; function statusNoise(observer: Observer) { - const stats = observer.statusMetadata?.stats; - return stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? `${stats.noise_floor} dBm` : "—"; + const n = observerNoiseFloor(observer); + return n == null ? "—" : `${n} dBm`; } export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: { @@ -23,7 +25,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u onSelect: (id: string) => void; onRefresh: () => number; }) { const { t, i18n } = useTranslation(); const colors = useChartColors(); - const valid = until != null && uuid.test(observerA.id) && uuid.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); + const valid = until != null && OBSERVER_UUID.test(observerA.id) && OBSERVER_UUID.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); const b = useObserver(valid ? observerBId : null); const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined); const dataB = activityB.isError ? undefined : activityB.data; @@ -52,9 +54,9 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u }, [activityA, dataB, aligned, intervalMs, start, end, colors]); const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); const groups = overlap.data && !overlap.isError ? [ - { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary }, - { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, - { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary }, + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary, textClass: "text-primary" }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green, textClass: "text-green" }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary, textClass: "text-secondary" }, ] : []; const refresh = () => { const nextUntil = onRefresh(); @@ -76,7 +78,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u ] : []; return

{t("observerCompare.title")}

-
+

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

{until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> @@ -88,7 +90,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <>

{t("observerCompare.total", { count: overlap.data.totalPackets })}

{overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} -
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}

{t("observerCompare.definition")}

} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 535e2be6..f65dd869 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -10,6 +10,7 @@ import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEV import { Timestamp } from "../../components/Timestamp"; import { useTick } from "../../hooks/useTick"; import { deriveObserverStatus } from "./observer-status"; +import { getStats } from "./observer-stats"; import type { BadgeVariant } from "../../components/badge-utils"; import { IataChip } from "../../components/IataChip"; import { ScopeTag } from "../../components/ScopeTag"; @@ -48,16 +49,6 @@ function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick?: ( ); } -interface Stats { - noise_floor?: number; - rx_air_secs?: number; - tx_air_secs?: number; - queue_len?: number; - recv_errors?: number; - errors?: number; - internal_heap?: number; -} - // broker freshness badge: <5m = live, <30m = stale function brokerStatusVariant(lastPacketAt: number | null): BadgeVariant { if (!lastPacketAt) return "offline"; @@ -65,12 +56,6 @@ function brokerStatusVariant(lastPacketAt: number | null): BadgeVariant { return ageMs < 5 * 60_000 ? "live" : ageMs < 30 * 60_000 ? "stale" : "offline"; } -// stats shape depends on the observer's firmware, so we just grab what we recognize -function getStats(metadata: Record | undefined): Stats | null { - if (!metadata?.stats || typeof metadata.stats !== "object") return null; - return metadata.stats as Stats; -} - function formatAirtime(secs: number): string { if (secs < 60) return `${secs}s`; if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; @@ -131,7 +116,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi title="Observer Detail" onClose={onClose} closeLabel={t("investigation.closeObserver")} - headerAction={} + headerAction={} isLoading={isLoading} notFound={!observer} notFoundLabel="Observer not found" diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index b860fc50..7c9fbca1 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -2,9 +2,11 @@ import { lazy, Suspense, useState, useEffect, useRef } from "react"; import { useLocation, useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { CopyButton } from "../../components/CopyButton"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { useTick } from "../../hooks/useTick"; import { ObserverTable } from "./ObserverTable"; import { observerDestination, observerRange } from "./observer-navigation"; +import { Segmented } from "../stats/Segmented"; import type { WsManager } from "../../api/ws-manager"; const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); @@ -53,16 +55,14 @@ export function ObserverPage({ wsManager, onReturn, returnLabel }: { wsManager: }; return
{(id || onReturn) &&
- {onReturn && } + {onReturn && } {id && <> - + - - + + {t("observerPage.range")} + setParams(observerDestination(params, id, observerRange(v)), visitOptions)} /> + }
}
diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx index 39b5fbff..c15d3e0d 100644 --- a/src/features/observers/ObserverPicker.tsx +++ b/src/features/observers/ObserverPicker.tsx @@ -1,24 +1,32 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; +import { SearchBar } from "../../components/SearchBar"; +import { SelectDropdown } from "../../components/SelectDropdown"; export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); - const [text, setText] = useState(""); const [query, setQuery] = useState(""); - useEffect(() => { const timer = setTimeout(() => setQuery(text.trim()), 250); return () => clearTimeout(timer); }, [text]); - const options = useQuery({ queryKey: ["observer-picker", regionKey, query], queryFn: () => getObserversPage(iatas, { name: query || undefined, limit: 50 }), staleTime: 30_000 }); + const options = useQuery({ queryKey: ["observer-picker", regionKey, query.trim()], queryFn: () => getObserversPage(iatas, { name: query.trim() || undefined, limit: 50 }), staleTime: 30_000 }); const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); return
- setText(e.target.value)} className="min-h-11 min-w-0 flex-1 rounded border border-border bg-bg-base px-3 text-text-normal" /> - + {}} value={query} onChange={setQuery} /> + o.id === id) ? [{ value: id, label: name }] : []), + ...rows.map(o => ({ value: o.id, label: `${o.displayName ?? o.id.slice(0, 8)} · ${o.iata}` })), + ]} + />
{options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

}
; diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index d5513dca..e7f5fdab 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -1,8 +1,11 @@ import { useTranslation } from "react-i18next"; import { Timestamp } from "../../components/Timestamp"; import { CopyButton } from "../../components/CopyButton"; -import { formatBattery, formatRadioParts, formatUptime } from "../../lib/formatters"; +import { formatBattery, formatRadioParts, formatUptime, formatUtc } from "../../lib/formatters"; import { useTick } from "../../hooks/useTick"; +import { observerNoiseFloor } from "./observer-stats"; +import { Card, StatCard } from "../stats/cards"; +import { Field } from "../../components/DetailPanel"; import type { Observer } from "./types"; import type { ObserverActivity, TelemetryPoint } from "../stats/types"; @@ -10,8 +13,7 @@ export function ObserverSummary({ observer, activity, points, pending = false }: const { t, i18n } = useTranslation(); const now = useTick(); const summary = activity?.summary; const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; - const stats = observer.statusMetadata?.stats; - const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; + const reportedNoise = observerNoiseFloor(observer); const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; const cards = [ ["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"], @@ -23,7 +25,7 @@ export function ObserverSummary({ observer, activity, points, pending = false }: ] as const; return <>
-

{observer.displayName ?? observer.id.slice(0, 8)}

+

{observer.displayName ?? observer.id.slice(0, 8)}

@@ -32,13 +34,16 @@ export function ObserverSummary({ observer, activity, points, pending = false }:
-
    - {cards.map(([key, value]) =>
  • -
    {t(`observerPage.${key}`)}
    -
    {value}
    - {key === "lastHour" && summary &&
    {new Date(summary.lastCompleteHourStart).toISOString().slice(11, 16)}–{new Date(summary.lastCompleteHourEnd).toISOString().slice(11, 16)} UTC
    } - {key === "noise" && noise != null &&
    {t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")}
    } -
  • )} +
      + {cards.map(([key, value]) => { + const note = key === "lastHour" && summary ? `${formatUtc(summary.lastCompleteHourStart, { timeOnly: true })}–${formatUtc(summary.lastCompleteHourEnd, { timeOnly: true })} UTC` + : key === "noise" && noise != null ? t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry") + : undefined; + return
    • + + {note &&

      {note}

      } +
    • ; + })}
    {!summary && !pending &&

    {t("observerPage.summaryMissing")}

    } ; @@ -47,13 +52,12 @@ export function ObserverSummary({ observer, activity, points, pending = false }: export function ObserverDeviceDetails({ observer }: { observer: Observer }) { const { t } = useTranslation(); const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); - return
    - {t("observerPage.details")} -
    - {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) =>
    {t(`observerPage.${key}`)}
    {value ?? "—"}
    )} -
    {t("observerPage.publicKey")}
    {observer.publicKey}
    -
    {t("observerPage.firstSeen")}
    -
    -
      {observer.brokers.map(b =>
    • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
    • )}
    -
    ; + return +
    + {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) => )} +
    {observer.publicKey}} />
    + } /> +
    +
      {observer.brokers.map(b =>
    • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
    • )}
    +
    ; } diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 110fa1ed..0eb39b43 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -114,7 +114,7 @@ export function ObserverTable({ compact = false, wsManager, selectedObserverId, const [typeFilter, setTypeFilter] = useState(""); const [brokerFilter, setBrokerFilter] = useState(""); const [scopeFilter, setScopeFilter] = useState(""); // "" = Any; applied client-side over the loaded set - // the compact sidebar only has a name search, so the directory's other filters sit out until it returns + // Compact mode has only the name search, so the other filters are ignored there. const status = compact ? "" : statusFilter; const type = compact ? "" : typeFilter; const broker = compact ? "" : brokerFilter; diff --git a/src/features/observers/observer-id.ts b/src/features/observers/observer-id.ts new file mode 100644 index 00000000..3695d5f8 --- /dev/null +++ b/src/features/observers/observer-id.ts @@ -0,0 +1,2 @@ +// excludes the nil UUID, which the API uses as a placeholder rather than a real observer id +export const OBSERVER_UUID = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts index b31869bb..b33fee79 100644 --- a/src/features/observers/observer-navigation.ts +++ b/src/features/observers/observer-navigation.ts @@ -5,7 +5,7 @@ export const observerRange = (value: string | null): StatsRange => value === "24 export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { const next = new URLSearchParams(params); next.set("tab", "Observers"); - for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation", "route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); + for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path", "observation", "route", "routeIata", "routeRange", "routeSince", "routeUntil", "compareA", "compareB", "compareSince"]) next.delete(key); if (id) { next.set("observer", id); next.set("range", range); } else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); } return next; diff --git a/src/features/observers/observer-stats.ts b/src/features/observers/observer-stats.ts new file mode 100644 index 00000000..2cd114b5 --- /dev/null +++ b/src/features/observers/observer-stats.ts @@ -0,0 +1,22 @@ +import type { Observer } from "./types"; + +export interface Stats { + noise_floor?: number; + rx_air_secs?: number; + tx_air_secs?: number; + queue_len?: number; + recv_errors?: number; + errors?: number; + internal_heap?: number; +} + +// stats shape depends on the observer's firmware, so we just grab what we recognize +export function getStats(metadata: Record | undefined): Stats | null { + if (!metadata?.stats || typeof metadata.stats !== "object") return null; + return metadata.stats as Stats; +} + +export function observerNoiseFloor(observer: Pick): number | null { + const stats = getStats(observer.statusMetadata); + return stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; +} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index ec0ec763..26c1ab36 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -49,8 +49,9 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o const [searchParams, setSearchParams] = useSearchParams(); const { t } = useTranslation(); - const hasPath = useMemo(() => (detail ? buildPacketPaths(detail).length > 0 : false), [detail]); - const observerCount = new Set(detail?.observations.map(o => o.observerId)).size; + const pathKeys = useMemo(() => new Set(detail ? buildPacketPaths(detail).map(p => p.key) : []), [detail]); + const hasPath = pathKeys.size > 0; + const observerCount = useMemo(() => new Set(detail?.observations.map(o => o.observerId)).size, [detail]); // drop ?analyze so a reload doesn't reopen the drawer; ?hash stays, leaving the row expanded const handleClose = useCallback(() => { @@ -132,7 +133,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {unavailable &&

    {t("investigation.unavailableReport")}

    } - +
    - {onViewObserver && } - + + {onViewObserver && } +
    {!canMap &&

    {t("investigation.unmappable")}

    } ; })}
)} - {groups.length > 3 && } + {groups.length > 3 && }

{t("investigation.retention")}

; } diff --git a/src/features/packets/PacketTableRow.tsx b/src/features/packets/PacketTableRow.tsx index 3cfa8870..165d5203 100644 --- a/src/features/packets/PacketTableRow.tsx +++ b/src/features/packets/PacketTableRow.tsx @@ -15,8 +15,7 @@ interface PacketTableRowProps { onToggle: () => void; } -// Fixed-height table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the -// expansion instead, which frees the wide column for the packet's endpoints. +// The observer lives in the expansion, which frees the wide column for the packet's endpoints. export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) { // ?? not ||, so a legitimate 0-hop direct packet still shows its count const pathLength = packet.latestObserver?.pathLength; diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index f0e62850..6009a080 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -1,5 +1,6 @@ import { useRef, useState, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react"; import { createPortal } from "react-dom"; +import { useTranslation } from "react-i18next"; import { useHasHover } from "../../hooks/useMediaQuery"; import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; import type { ResolvedHop, ResolvedNode } from "../../types/api"; @@ -23,6 +24,7 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: { showSnr?: boolean; children: ReactNode; }) { + const { t } = useTranslation(); const hasHover = useHasHover(); const ref = useRef(null); const tipRef = useRef(null); @@ -108,7 +110,7 @@ export function HopPopover({ hop, onViewNode, showSnr = true, children }: { className={`fixed z-50 flex flex-col gap-0.5 whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg ${clickable ? "" : "pointer-events-none"}`} > {nodes.length === 0 ? ( - "No Path Resolutions Available" + t("packetRow.noResolution") ) : clickable ? ( nodes.map((node) => (
} + {query.isError &&

{t(isNotFound(query.error) ? "routeEvidence.missing" : "routeEvidence.error")}

} {first && <>

{t("routeEvidence.width", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

@@ -56,13 +56,13 @@ export function RouteEvidencePanel({ iata, pathKey, onClose, onAnalyzePacket, on

{t("routeEvidence.brief")}

{t("routeEvidence.definition")}

{t("routeEvidence.match", { width: first.matchAvailable && first.hashSize ? first.hashSize : "—" })}

{t("routeEvidence.caution")}

{t("routeEvidence.retention")}

- {!first.matchAvailable ?

{t("routeEvidence.unavailable")}

: reports.length === 0 ?

{t("routeEvidence.empty")}

:
    {reports.map(report =>
  • + {!first.matchAvailable ?

    {t("routeEvidence.unavailable")}

    : reports.length === 0 ?

    {t("routeEvidence.empty")}

    :
      {reports.map(report => { const level = snrLevel(report.snr); return
    • {report.packetHash.slice(0, 8).toUpperCase()}{report.payloadTypeName}

      {report.observerName ?? report.observerId.slice(0, 8)}

      -

      · SNR {report.snr == null ? "—" : `${report.snr} dB`} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

      -
      {onAnalyzePacket && }{onViewObserver && }
      -
    • )}
    } - {query.hasNextPage && } +

    · SNR {formatSnr(report.snr)}{report.snr != null && " dB"} · RSSI {report.rssi == null ? "—" : `${report.rssi} dBm`}

    +
    {onAnalyzePacket && }{onViewObserver && }
    +
  • ; })}
} + {query.hasNextPage && } {capped &&

{t("routeEvidence.cap")}

}
} diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 9ba9e45c..ba16d5a6 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -150,9 +150,11 @@ export function RouteTable(actions: RouteActions) { const pathKey = params.get("route"), routeIata = params.get("routeIata"); const closeRoute = useCallback(() => { + // no-op when nothing is open, so callers (e.g. the region-change effect) can call it unconditionally + if (!pathKey && !selectedKey) return; setSelectedKey(null); setParams(previous => { const next = new URLSearchParams(previous); for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key); return next; }, { replace: true }); - }, [setParams]); + }, [pathKey, selectedKey, setParams]); // drop the selection when the region changes — the selected route may not be in the new region diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 56158bae..2956a376 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -4,14 +4,14 @@ import { useSearchParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { getObserver, getObserverComparison, getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; +import { OBSERVER_UUID } from "../observers/observer-id"; import { Card } from "./cards"; type Selection = { observerA: string; observerB: string; since: number; until: number }; -const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; const fieldClass = "min-w-0 w-full rounded border border-border bg-bg-base px-2.5 py-2 text-base text-text-normal"; function validation(value: Selection): string | null { - if (!uuid.test(value.observerA) || !uuid.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { + if (!OBSERVER_UUID.test(value.observerA) || !OBSERVER_UUID.test(value.observerB) || value.observerA.toLowerCase() === value.observerB.toLowerCase()) { return "Choose two different observers."; } if (!Number.isSafeInteger(value.since) || !Number.isSafeInteger(value.until) || value.since < 0 || value.until <= value.since || value.until > 253402300799999) { @@ -40,7 +40,7 @@ function ObserverSelect({ label, value, onChange }: { label: string; value: stri }); const selected = useQuery({ queryKey: ["observer", value], queryFn: () => getObserver(value), - enabled: uuid.test(value), staleTime: 30_000, retry: false, + enabled: OBSERVER_UUID.test(value), staleTime: 30_000, retry: false, }); const rows = options.data?.items ?? []; return ( diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index 7a0cc23d..30f960a8 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -103,7 +103,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [nodeTypesData, colors], ); const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]); - const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]); + const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), t("mesh.nodesCenter")), [typeRows, colors, typeTotal, t]); const presetRows = useMemo( () => aggregatePresets(radioPresetsData ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })), @@ -126,14 +126,14 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { return (
- - - - + + + +
Observations · {range}} + title={t("mesh.observationsTitle", { range })} height={200} option={obsOption} isLoading={observations.isPending || observations.isPlaceholderData} @@ -144,11 +144,11 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
{/* range-driven charts lead the grid; the all-time ones follow below */}
- Top observers · {range}} height={208} option={observersOption} isLoading={topObservers.isPending || topObservers.isPlaceholderData} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} /> +
Payload types · {range}} - right={{formatCount(payloadData === undefined ? undefined : payloadTotal)} obs} + title={t("mesh.payloadTypes", { range })} + right={{t("mesh.obs", { value: formatCount(payloadData === undefined ? undefined : payloadTotal) })}} height={208} option={payloadOption} isLoading={payload.isPending || payload.isPlaceholderData} @@ -156,27 +156,27 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isEmpty={payloadItems.length === 0} /> {/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */} - - - + + + -
- {t("stats.tabs.scopes")} +
+ {t("stats.tabs.scopes")}
{scopes.isError ? ( -
Failed to load
+
{t("common.loadFailed")}
) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? ( -
Loading…
+
{t("common.loading")}
) : scopeRows.length === 0 ? ( -
No data
+
{t("common.noData")}
) : ( - - - - + + + + diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index c101d383..70640715 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; import { EmptyState } from "../../components/EmptyState"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { formatRadioParts } from "../../lib/formatters"; import { ObserverComparison } from "../observers/ObserverComparison"; import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; @@ -52,8 +53,8 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), [points, bucketMs], ); - const battery = useMemo(() => batteryOption(points, colors, t("observerPage.battery") + " V"), [points, colors, t]); - const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noise") + " dBm"), [points, colors, t]); + const battery = useMemo(() => batteryOption(points, colors, t("observerPage.batteryV")), [points, colors, t]); + const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noiseDbm")), [points, colors, t]); const queue = useMemo(() => queueOption(points, colors, t("observerPage.queue")), [points, colors, t]); const recvErrors = useMemo( () => receiveErrorsOption(points, colors, bucketed, t("observerPage.errors")), @@ -124,7 +125,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } return (

{t("observerPage.loadFailed")}

-
@@ -186,7 +187,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } /> {activity.isError && ( - )} @@ -246,7 +247,7 @@ export function ObserverTab({ range, selectedObserverId, wsManager, comparison } /> {telemetry.isError && ( - )} diff --git a/src/features/stats/PathsTab.tsx b/src/features/stats/PathsTab.tsx index 97c512fb..5cbe271e 100644 --- a/src/features/stats/PathsTab.tsx +++ b/src/features/stats/PathsTab.tsx @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { donutOption } from "./chartOptions"; import { tooltipStyle, useChartColors } from "./chartTheme"; @@ -8,8 +8,6 @@ import { pathHours, pathLengthOption, pathTrendOption } from "./paths"; import { usePathStats } from "./usePathStats"; import type { StatsRange } from "./types"; -const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); - export function PathsTab({ range }: { range: StatsRange }) { const { t } = useTranslation(); const query = usePathStats(range), c = useChartColors(); @@ -48,7 +46,7 @@ export function PathsTab({ range }: { range: StatsRange }) {

{t("paths.measurement")}

- {data &&

{t("paths.window", { since: utc(data.since), until: utc(data.until) })}

} + {data &&

{t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })}

}
@@ -65,13 +63,13 @@ export function PathsTab({ range }: { range: StatsRange }) {
{data && data.receptions > 0 &&
- {t("paths.details")} -
+ {t("paths.details")} +
ScopePacketsObserversNodes{t("mesh.scope")}{t("mesh.packets")}{t("mesh.observers")}{t("mesh.nodes")}
{data.hashWidths.map((bin) => )}
{t("paths.bytesPerHash")}{t("paths.receptionsColumn")}
{bin.bytes}{bin.receptions.toLocaleString()}
{data.pathLengths.map((bin) => )}
{t("paths.headerEntries")}{t("paths.receptionsColumn")}
{bin.entries}{bin.receptions.toLocaleString()}

{t("paths.lengthsHelp")}

-
{[t("paths.utcHour"), t("paths.receptionsColumn"), ...[1, 2, 3].map((count) => t("paths.hashWidth", { count })), t("paths.categories.empty"), t("paths.categories.trace"), t("paths.categories.unclassified")].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{utc(row.hour)}{n.toLocaleString()}
+
{[t("paths.utcHour"), t("paths.receptionsColumn"), ...[1, 2, 3].map((count) => t("paths.hashWidth", { count })), t("paths.categories.empty"), t("paths.categories.trace"), t("paths.categories.unclassified")].map((label) => )}{data.hourly.map((row) => {[row.receptions, row.oneByte, row.twoByte, row.threeByte, row.empty, row.trace, row.unclassified].map((n, i) => )})}
{label}
{formatUtc(row.hour)}{n.toLocaleString()}
}
; } diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index 400dbf59..e7188dc8 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -1,13 +1,12 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { useChartColors } from "./chartTheme"; import { signalBinLabel, signalCoverageOption, signalHistogramOption, signalHours, signalTrendOption } from "./signal"; import { useSignalStats } from "./useSignalStats"; import type { StatsRange } from "./types"; -const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " "); const average = (value: number | null | undefined, unit = "") => value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; export function SignalTab({ range }: { range: StatsRange }) { @@ -32,12 +31,12 @@ export function SignalTab({ range }: { range: StatsRange }) { {query.isError &&

{t("signal.error")}

}
- {average(data?.snr.average, "dB")}} accent={c.secondary} /> - {average(data?.rssi.average, "dBm")}} accent={c.green} /> + +

{t("signal.measurement")}

- {data &&

{t("signal.window", { since: utc(data.since), until: utc(data.until) })}

} + {data &&

{t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })}

}
@@ -57,8 +56,8 @@ export function SignalTab({ range }: { range: StatsRange }) {
{data && data.receptions > 0 &&
- {t("signal.details")} -
+ {t("signal.details")} +
{(["snr", "rssi"] as const).map((metric) => {data[metric].histogram.map((bin, i) => )} @@ -67,7 +66,7 @@ export function SignalTab({ range }: { range: StatsRange }) {

{t("signal.binsHelp")}

{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}{t("signal.samples")}
{signalBinLabel(bin, t)}{bin.count.toLocaleString()}
- {data.hourly.map((row) => )} + {data.hourly.map((row) => )}
{t("signal.utcHour")}{t("signal.receptionsColumn")}{t("signal.metricSamples", { metric: "SNR" })}{t("signal.meanUnit", { unit: "dB" })}{t("signal.metricSamples", { metric: "RSSI" })}{t("signal.meanUnit", { unit: "dBm" })}
{utc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
{formatUtc(row.hour)}{row.receptions.toLocaleString()}{row.snrSamples.toLocaleString()}{average(row.snrAverage)}{row.rssiSamples.toLocaleString()}{average(row.rssiAverage)}
}
diff --git a/src/features/stats/TalkersTab.tsx b/src/features/stats/TalkersTab.tsx index 6caf0f19..17114b2f 100644 --- a/src/features/stats/TalkersTab.tsx +++ b/src/features/stats/TalkersTab.tsx @@ -26,8 +26,8 @@ export function TalkersTab({ range }: TalkersTabProps) { const topAdvertisers = useTopAdvertisers(range, 20); const topTalkers = useTopTalkers(range, 20); - const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isLoading || topAdvertisers.isPlaceholderData; - const talkersLoading = topTalkers.isPending || topTalkers.isLoading || topTalkers.isPlaceholderData; + const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData; + const talkersLoading = topTalkers.isPending || topTalkers.isPlaceholderData; const talkersUnavailable = talkersLoading || topTalkers.isError; const advertisers = advertisersLoading || topAdvertisers.isError ? [] : (topAdvertisers.data ?? []); diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts index 50624d36..e0c9f19f 100644 --- a/src/features/stats/useLiveStats.ts +++ b/src/features/stats/useLiveStats.ts @@ -21,6 +21,8 @@ export function useLiveOverview(wsManager: WsManager) { const { packets, obs } = pending.current; if (!packets && !obs) return; pending.current = { packets: 0, obs: 0 }; + // setQueryData would clear an error state and show stale numbers as healthy — drop the deltas instead. + if (qc.getQueryState(["stats-overview", regionKey])?.status !== "success") return; qc.setQueryData(["stats-overview", regionKey], (old) => old ? { ...old, totalPackets: old.totalPackets + packets, totalObservations: old.totalObservations + obs } diff --git a/src/features/stats/usePathStats.ts b/src/features/stats/usePathStats.ts index 6a9668cf..3fe7e7c7 100644 --- a/src/features/stats/usePathStats.ts +++ b/src/features/stats/usePathStats.ts @@ -1,22 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; import { getPathStats } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; -import { RANGE_MS, type StatsRange } from "./types"; +import { useWindowedStats } from "./useWindowedStats"; +import type { StatsRange } from "./types"; export function usePathStats(range: StatsRange) { - const { iatas, regionKey, isResolved } = useRegion(); - return useQuery({ - queryKey: ["stats-paths", isResolved === false ? `${regionKey}:pending` : regionKey, range], - enabled: isResolved !== false, - queryFn: ({ signal }) => { - if (isResolved === false) throw new Error("Selected region is not available yet"); - // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. - const until = Math.floor(Date.now() / 60_000) * 60_000; - return getPathStats(until - RANGE_MS[range], until, iatas, signal); - }, - staleTime: 30_000, - refetchInterval: 60_000, - refetchOnWindowFocus: false, - retry: false, - }); + return useWindowedStats("stats-paths", range, getPathStats); } diff --git a/src/features/stats/useSignalStats.ts b/src/features/stats/useSignalStats.ts index 604d26fb..297b55a6 100644 --- a/src/features/stats/useSignalStats.ts +++ b/src/features/stats/useSignalStats.ts @@ -1,22 +1,7 @@ -import { useQuery } from "@tanstack/react-query"; import { getSignalStats } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; -import { RANGE_MS, type StatsRange } from "./types"; +import { useWindowedStats } from "./useWindowedStats"; +import type { StatsRange } from "./types"; export function useSignalStats(range: StatsRange) { - const { iatas, regionKey, isResolved } = useRegion(); - return useQuery({ - queryKey: ["stats-signal", isResolved === false ? `${regionKey}:pending` : regionKey, range], - enabled: isResolved !== false, - queryFn: ({ signal }) => { - if (isResolved === false) throw new Error("Selected region is not available yet"); - // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. - const until = Math.floor(Date.now() / 60_000) * 60_000; - return getSignalStats(until - RANGE_MS[range], until, iatas, signal); - }, - staleTime: 30_000, - refetchInterval: 60_000, - refetchOnWindowFocus: false, - retry: false, - }); + return useWindowedStats("stats-signal", range, getSignalStats); } diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index 4736e7c1..d2edd306 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -15,7 +15,7 @@ import { } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; -// Shared query options: cache for 30s, keep previous data so region/range switches don't flash. +// Shared query options: cache for 30s; previous data stays as placeholder so tabs can decide whether to show it during a switch. const common = { staleTime: 30_000, placeholderData: keepPreviousData, diff --git a/src/features/stats/useWindowedStats.ts b/src/features/stats/useWindowedStats.ts new file mode 100644 index 00000000..f6209283 --- /dev/null +++ b/src/features/stats/useWindowedStats.ts @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useRegion } from "../../hooks/useRegion"; +import { RANGE_MS, type StatsRange } from "./types"; + +// Shared by usePathStats/useSignalStats (and any future /stats/* window query): resolves the +// region, floors the window to minute boundaries and polls every minute. +export function useWindowedStats( + key: string, + range: StatsRange, + fetch: (since: number, until: number, iatas: string[] | undefined, signal?: AbortSignal) => Promise, +) { + const { iatas, regionKey, isResolved } = useRegion(); + return useQuery({ + queryKey: [key, isResolved === false ? `${regionKey}:pending` : regionKey, range], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. + const until = Math.floor(Date.now() / 60_000) * 60_000; + return fetch(until - RANGE_MS[range], until, iatas, signal); + }, + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + }); +} diff --git a/src/i18n/index.ts b/src/i18n/index.ts index f4cab08b..51cc930a 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -31,6 +31,8 @@ i18n.on("languageChanged", () => { const language = i18n.resolvedLanguage ?? "en"; document.documentElement.lang = language; document.documentElement.dir = i18n.dir(language); + // init() itself fires this event before any user choice exists — don't persist that first, implicit one. + if (!i18n.isInitialized) return; try { localStorage.setItem("beacon-language", language); } catch { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4ae076e6..f096e633 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -47,6 +47,24 @@ "noData": "No data", "dataUnavailable": "Data unavailable" }, + "mesh": { + "totalPackets": "Total packets", + "observations": "Observations", + "activeObservers": "Active observers", + "activeIatas": "Active IATAs", + "observationsTitle": "Observations · {{range}}", + "topObservers": "Top observers · {{range}}", + "payloadTypes": "Payload types · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Top nodes · all time", + "nodeTypes": "Node types · all time", + "radioPresets": "Radio presets · all time", + "scope": "Scope", + "packets": "Packets", + "observers": "Observers", + "nodes": "Nodes", + "nodesCenter": "NODES" + }, "stats": { "section": "Stats section", "mobileSection": "Section", @@ -211,6 +229,15 @@ "other": "Other scopes", "chartDescription": "Counts by transport scope. Exact values for all matching scopes are listed in the table." }, + "nodeDetail": { + "viewOnMap": "View on map" + }, + "nodes": { + "lastHeard": "last heard {{ago}}" + }, + "packetRow": { + "noResolution": "No path resolution available" + }, "clockDrift": { "caption": "Repeaters & room servers out of sync · worst first", "node": "Node", @@ -222,7 +249,13 @@ "empty": "No repeaters out of sync" }, "timestamp": { - "ago": "{{duration}} ago" + "ago": "{{duration}} ago", + "unit": { + "s": "{{count}}s", + "m": "{{count}}m", + "h": "{{count}}h", + "d": "{{count}}d" + } }, "observerPage": { "back": "Back to observers", @@ -234,8 +267,10 @@ "lastHour": "Last complete hour", "lastPacket": "Last recorded packet", "battery": "Battery", + "batteryV": "Battery V", "uptime": "Device uptime", "noise": "Noise floor", + "noiseDbm": "Noise dBm", "packets": "Packets", "search": "Find an observer", "choose": "Choose an observer", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 35178e7e..e7d5c956 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -24,7 +24,7 @@ "live": "EN DIRECT", "stale": "EN RETARD {{age}}", "offline": "HORS LIGNE", - "rateLimited": "RÉESSAI DANS {{seconds}} s" + "rateLimited": "DÉBIT LIMITÉ {{seconds}} s" }, "region": { "label": "RÉGION", @@ -48,6 +48,24 @@ "noData": "Aucune donnée", "dataUnavailable": "Données indisponibles" }, + "mesh": { + "totalPackets": "Paquets totaux", + "observations": "Observations", + "activeObservers": "Observateurs actifs", + "activeIatas": "IATA actifs", + "observationsTitle": "Observations · {{range}}", + "topObservers": "Meilleurs observateurs · {{range}}", + "payloadTypes": "Types de paquets · {{range}}", + "obs": "{{value}} obs", + "topNodes": "Meilleurs nœuds · historique complet", + "nodeTypes": "Types de nœuds · historique complet", + "radioPresets": "Préréglages radio · historique complet", + "scope": "Scope", + "packets": "Paquets", + "observers": "Observateurs", + "nodes": "Nœuds", + "nodesCenter": "NŒUDS" + }, "stats": { "section": "Section des statistiques", "mobileSection": "Section", @@ -215,6 +233,15 @@ "other": "Autres scopes", "chartDescription": "Effectifs par scope de transport. Les valeurs exactes de tous les scopes correspondants figurent dans le tableau." }, + "nodeDetail": { + "viewOnMap": "Voir sur la carte" + }, + "nodes": { + "lastHeard": "dernier contact {{ago}}" + }, + "packetRow": { + "noResolution": "Aucune résolution de chemin disponible" + }, "clockDrift": { "caption": "Répéteurs et serveurs de salon désynchronisés · écart maximal en premier", "node": "Nœud", @@ -226,7 +253,13 @@ "empty": "Aucun répéteur désynchronisé" }, "timestamp": { - "ago": "il y a {{duration}}" + "ago": "il y a {{duration}}", + "unit": { + "s": "{{count}} s", + "m": "{{count}} min", + "h": "{{count}} h", + "d": "{{count}} j" + } }, "observerPage": { "back": "Retour aux observateurs", @@ -238,8 +271,10 @@ "lastHour": "Dernière heure complète", "lastPacket": "Dernier paquet enregistré", "battery": "Batterie", + "batteryV": "Batterie V", "uptime": "Durée de fonctionnement", "noise": "Bruit de fond", + "noiseDbm": "Bruit dBm", "packets": "Paquets", "search": "Rechercher un observateur", "choose": "Choisir un observateur", diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 22b30a59..b30eb4a4 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -16,6 +16,14 @@ export function formatAbsolute(epochMs: number, opts?: { ms?: boolean }): string return opts?.ms ? `${base}.${pad(d.getMilliseconds(), 3)}` : base; } +// The UTC counterpart to formatAbsolute, for charts/tables anchored to server-side hour/minute +// boundaries rather than the viewer's local time. +export function formatUtc(epochMs: number, opts?: { seconds?: boolean; timeOnly?: boolean }): string { + const iso = new Date(epochMs).toISOString(); + const date = iso.slice(0, 10), time = iso.slice(11, opts?.seconds ? 19 : 16); + return opts?.timeOnly ? time : `${date} ${time}`; +} + // signal quality and radio metric formatting export type SignalLevel = "good" | "mid" | "bad"; @@ -99,15 +107,24 @@ export function formatRatePerDay(count: number | null | undefined, windowMs: num return `${shown}/d`; } +export type TimeAgoUnit = "s" | "m" | "h" | "d"; + // clamp negative values from clock skew -export function timeAgoMs(epochMs: number): string { +export function timeAgoParts(epochMs: number): { count: number; unit: TimeAgoUnit } { const seconds = Math.max(0, Math.floor((Date.now() - epochMs) / 1000)); - if (seconds < 60) return `${seconds}s`; + if (seconds < 60) return { count: seconds, unit: "s" }; const minutes = Math.floor(seconds / 60); - if (minutes < 60) return `${minutes}m`; + if (minutes < 60) return { count: minutes, unit: "m" }; const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h`; - return `${Math.floor(hours / 24)}d`; + if (hours < 24) return { count: hours, unit: "h" }; + return { count: Math.floor(hours / 24), unit: "d" }; +} + +// Compact English "7d" matching Timestamp's English relative phrasing; kept for tests that assert +// against it, not used by app code (see timeAgoParts for the i18n path). +export function timeAgoMs(epochMs: number): string { + const { count, unit } = timeAgoParts(epochMs); + return `${count}${unit}`; } // One radio config format for every panel ("915 MHz · SF11 · 250 kHz · CR 4/5"); unknown or zero parts drop out. diff --git a/src/types/api.ts b/src/types/api.ts index 5de0906a..7f4ab364 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -35,7 +35,7 @@ export interface PacketSummary { observationCount: number; latestObserver?: LatestObserver; scope?: string; // matched transport scope name, e.g. "#bc" - summary?: string; // packet-derived display text, currently the advertised name + summary?: string; // packet-derived display text (advert name, TRACE tag, ACK checksum, …) } export interface ResolvedNode { diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index cf258dee..69dbd851 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -14,7 +14,7 @@ vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: asy vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children, onTabChange }: { children: ReactNode; onTabChange: (tab: string) => void }) => { const { setSelection } = useRegionSelection(); - return <>{children}; + return <>{children}; } })); vi.mock("../src/features/routes/RouteTable", () => ({ RouteTable: ({ onViewObserver, onAnalyzePacket, onViewNode }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string, id: number) => void; onViewNode: (id: string) => void }) => { const [filter, setFilter] = useState(""); const [params] = useSearchParams(); @@ -84,7 +84,7 @@ describe("observer investigation return", () => { expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d"); expect(window.location.search).toContain("tab=Observers&observer=o1"); expect(screen.getByTestId("origin-url")).toHaveTextContent("route=full-route"); - fireEvent.change(screen.getByRole("combobox", { name: "Time range" }), { target: { value: "24h" } }); + fireEvent.click(within(screen.getByRole("group", { name: "Time range" })).getByRole("button", { name: "24h" })); click("Compare with…"); expect(window.location.search).toContain("compareWith="); click("Back to Routes"); @@ -132,4 +132,30 @@ describe("observer investigation return", () => { await i18n.changeLanguage("fr"); render(); click("Route observer"); click("Open dashboard"); expect(await screen.findByRole("button", { name: /Retour à « Routes »/ })).toBeInTheDocument(); }); + it("clears comparison params from both the observer dashboard and analytics compare tabs on tab change", () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=1700000000000&compareA=x&compareB=y&compareSince=1"); + render(); + click("Route tab"); + expect(window.location.search).toContain("tab=Routes"); + for (const key of ["observer", "compareWith", "compareUntil", "compareA", "compareB", "compareSince"]) { + expect(window.location.search).not.toContain(key); + } + }); + it("keeps analytics compare params when re-clicking the already-active Analytics tab", async () => { + await import("../src/features/stats/StatsOverview"); + window.history.replaceState({}, "", "/?tab=Analytics&statsTab=compare&compareA=a&compareB=b&compareSince=1&compareUntil=2"); + render(); + click("Analytics tab"); + for (const pair of ["compareA=a", "compareB=b", "compareSince=1", "compareUntil=2"]) { + expect(window.location.search).toContain(pair); + } + }); + it("keeps the observer dashboard's compare params when re-clicking the already-active Observer tab", () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1&compareWith=o2&compareUntil=2"); + render(); + click("Observer tab"); + for (const pair of ["observer=o1", "compareWith=o2", "compareUntil=2"]) { + expect(window.location.search).toContain(pair); + } + }); }); diff --git a/tests/components/Timestamp.test.tsx b/tests/components/Timestamp.test.tsx index c8bd64ad..2bb39648 100644 --- a/tests/components/Timestamp.test.tsx +++ b/tests/components/Timestamp.test.tsx @@ -33,21 +33,22 @@ describe("Timestamp", () => { }); it.each([ - [59_999, "59s"], - [60_000, "1m"], - [3_600_000, "1h"], - [86_400_000, "1d"], - [-60_000, "0s"], - ])("translates the phrase while preserving duration %s", async (age, duration) => { + [59_999, "59s", "59 s"], + [60_000, "1m", "1 min"], + [3_600_000, "1h", "1 h"], + [86_400_000, "1d", "1 j"], + [7 * 86_400_000, "7d", "7 j"], + [-60_000, "0s", "0 s"], + ])("translates the phrase while preserving duration %s", async (age, enDuration, frDuration) => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 8, 25, 12, 0, 0)); render(); - const label = screen.getByText(`${duration} ago`); + const label = screen.getByText(`${enDuration} ago`); await act(() => i18n.changeLanguage("fr")); - expect(screen.getByText(`il y a ${duration}`)).toBe(label); + expect(screen.getByText(`il y a ${frDuration}`)).toBe(label); await act(() => i18n.changeLanguage("en")); - expect(screen.getByText(`${duration} ago`)).toBe(label); + expect(screen.getByText(`${enDuration} ago`)).toBe(label); }); it("updates an open relative tooltip without changing the absolute value or milliseconds", async () => { @@ -61,7 +62,7 @@ describe("Timestamp", () => { expect(screen.getByRole("tooltip")).toHaveTextContent("5m ago"); await act(() => i18n.changeLanguage("fr")); - expect(screen.getByRole("tooltip")).toHaveTextContent("il y a 5m"); + expect(screen.getByRole("tooltip")).toHaveTextContent("il y a 5 min"); expect(screen.getByText("2026-09-25 12:34:56.123")).toBe(label); expect(label).toHaveClass("timestamp-label"); }); @@ -76,10 +77,10 @@ describe("Timestamp", () => { expect(vi.getTimerCount()).toBe(1); await act(() => i18n.changeLanguage("fr")); - expect(screen.getAllByText("il y a 1m")).toHaveLength(100); + expect(screen.getAllByText("il y a 1 min")).toHaveLength(100); expect(vi.getTimerCount()).toBe(1); act(() => vi.advanceTimersByTime(60_000)); - expect(screen.getAllByText("il y a 2m")).toHaveLength(100); + expect(screen.getAllByText("il y a 2 min")).toHaveLength(100); expect(vi.getTimerCount()).toBe(1); unmount(); expect(vi.getTimerCount()).toBe(0); diff --git a/tests/features/channels/ChannelList.test.tsx b/tests/features/channels/ChannelList.test.tsx index 30ea605e..3850f9eb 100644 --- a/tests/features/channels/ChannelList.test.tsx +++ b/tests/features/channels/ChannelList.test.tsx @@ -1,7 +1,9 @@ import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, type InfiniteData } from "@tanstack/react-query"; import { ChannelList } from "../../../src/features/channels/ChannelList"; +import { noteRateLimited, noteRequestOk } from "../../../src/api/rate-limit"; +import { LIVE_BUFFER_CAP } from "../../../src/lib/constants"; import type { ChannelMessage, ChannelSummary } from "../../../src/features/channels/types"; import type { CursorPage } from "../../../src/types/api"; import type { WsManager } from "../../../src/api/ws-manager"; @@ -29,13 +31,21 @@ function show(client = new QueryClient({ defaultOptions: { queries: { retry: fal return { ...render(app()), client, app }; } function lastURL() { return new URL(String(fetchMock.mock.calls.at(-1)![0])); } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} beforeEach(() => { region = { iatas: ["YYZ"], regionKey: "YYZ" }; fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); -afterEach(() => vi.unstubAllGlobals()); +afterEach(() => { + vi.unstubAllGlobals(); + noteRequestOk(); +}); describe("channel directory paging", () => { it("prefers the precise server cursor and preserves it through live updates", async () => { @@ -174,3 +184,46 @@ describe("channel directory paging", () => { expect(lastURL().searchParams.has("cursor")).toBe(false); }); }); + +describe("channel message overflow recovery", () => { + it("recovers a rate-limit-stuck overflow once the rate limit clears", async () => { + fetchMock.mockResolvedValueOnce(response(first())); + const { client } = show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByText("#Channel 1")); + const key = ["channel-messages", 1, "YYZ", ""]; + const fetch = deferred>>(); + void client.fetchQuery({ queryKey: key, queryFn: () => fetch.promise }); + await waitFor(() => expect(client.isFetching({ queryKey: key, exact: true })).toBe(1)); + act(() => { + for (let i = 0; i <= LIVE_BUFFER_CAP; i++) { + onMessage({ id: i, packetHash: `h${i}`, channelHash: "01", senderName: "s", content: "c", sentAt: 20000 + i }); + } + }); + noteRateLimited(60_000); + await act(async () => fetch.resolve({ pages: [{ items: [], nextCursor: null, hasMore: false }], pageParams: [undefined] })); + await waitFor(() => expect(client.isFetching({ queryKey: key, exact: true })).toBe(0)); + expect(client.getQueryState(key)?.isInvalidated).toBeFalsy(); + act(() => noteRequestOk()); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); + + it("invalidates an idle overflow directly from the WS handler once a further message arrives", async () => { + fetchMock.mockResolvedValueOnce(response(first())); + const { client } = show(); + await screen.findByText("#Channel 1"); + fireEvent.click(screen.getByText("#Channel 1")); + const key = ["channel-messages", 1, "YYZ", ""]; + // A registered-but-unfetched query: getQueryData(key) stays undefined, so messages keep hitting the queue/overflow path. + client.getQueryCache().build(client, { queryKey: key }); + expect(client.isFetching({ queryKey: key, exact: true })).toBe(0); + act(() => { + for (let i = 0; i < LIVE_BUFFER_CAP; i++) { + onMessage({ id: i, packetHash: `h${i}`, channelHash: "01", senderName: "s", content: "c", sentAt: 20000 + i }); + } + }); + expect(client.getQueryState(key)?.isInvalidated).toBeFalsy(); + act(() => onMessage({ id: LIVE_BUFFER_CAP, packetHash: `h${LIVE_BUFFER_CAP}`, channelHash: "01", senderName: "s", content: "c", sentAt: 30000 })); + expect(client.getQueryState(key)?.isInvalidated).toBe(true); + }); +}); diff --git a/tests/features/map/useMapBordersData.refresh.test.tsx b/tests/features/map/useMapBordersData.refresh.test.tsx index 5add918f..297c861e 100644 --- a/tests/features/map/useMapBordersData.refresh.test.tsx +++ b/tests/features/map/useMapBordersData.refresh.test.tsx @@ -26,4 +26,21 @@ describe("border refresh", () => { rerender(); expect(result.current.features).toHaveLength(1); }); + it("keeps the same reference when a refetch returns the same data object", () => { + const same = border(0); + state.data = same; state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YKF"], true)); + const previous = result.current; + state.updated = 2; + rerender(); + expect(result.current).toBe(previous); + }); + it("keeps the same reference when a null-border query refetches", () => { + state.data = null; state.updated = 1; + const { result, rerender } = renderHook(() => useMapBordersData(["YOW"], true)); + const previous = result.current; + state.updated = 2; + rerender(); + expect(result.current).toBe(previous); + }); }); diff --git a/tests/features/nodes/NodeDetailPanel.test.tsx b/tests/features/nodes/NodeDetailPanel.test.tsx index f95a0235..923226cb 100644 --- a/tests/features/nodes/NodeDetailPanel.test.tsx +++ b/tests/features/nodes/NodeDetailPanel.test.tsx @@ -1,8 +1,9 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel"; +import i18n from "../../../src/i18n"; import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client"; import type { Node, NodeNeighbor } from "../../../src/features/nodes/types"; @@ -99,6 +100,18 @@ describe("NodeDetailPanel neighbors", () => { }); }); +describe("NodeDetailPanel location", () => { + it("hides Lat/Lng but keeps the source for a 0/0 advert reset", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0, locationSource: "advert" }); + + renderPanel(); + + expect(await screen.findByText("Source")).toBeInTheDocument(); + expect(screen.queryByText("Lat")).not.toBeInTheDocument(); + expect(screen.queryByText("Lng")).not.toBeInTheDocument(); + }); +}); + describe("NodeDetailPanel clock drift", () => { it("shows a repeater's clock drift in amber when the server flags it out of sync", async () => { mockGetNode.mockResolvedValue({ ...node, lastAdvertAt: 2, clockDriftSeconds: 432, clockOutOfSync: true, clockCheckedAt: 2 }); @@ -156,4 +169,27 @@ describe("NodeDetailPanel View on map", () => { await screen.findByText("Self Node"); expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); }); + + it("shows the French label", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 45.42153, lng: -75.69719 }); + await act(() => i18n.changeLanguage("fr")); + renderWithMap(vi.fn()); + expect(await screen.findByRole("button", { name: "Voir sur la carte" })).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); + }); + + it("is not offered for an explicit 0/0 advert reset", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 0 }); + renderWithMap(vi.fn()); + await screen.findByText("Self Node"); + expect(screen.queryByRole("button", { name: "View on map" })).not.toBeInTheDocument(); + }); + + it("is still offered when only one axis is zero", async () => { + mockGetNode.mockResolvedValue({ ...node, lat: 0, lng: 10 }); + const onViewOnMap = vi.fn(); + renderWithMap(onViewOnMap); + fireEvent.click(await screen.findByRole("button", { name: "View on map" })); + expect(onViewOnMap).toHaveBeenCalledWith(0, 10); + }); }); diff --git a/tests/features/nodes/NodeTable.test.tsx b/tests/features/nodes/NodeTable.test.tsx new file mode 100644 index 00000000..35836c7a --- /dev/null +++ b/tests/features/nodes/NodeTable.test.tsx @@ -0,0 +1,76 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, within, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { NodeTable } from "../../../src/features/nodes/NodeTable"; +import { getNodesPage } from "../../../src/api/client"; +import i18n from "../../../src/i18n"; +import type { NodeSummary } from "../../../src/features/nodes/types"; +import type { WsManager } from "../../../src/api/ws-manager"; + +const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); +vi.mock("../../../src/hooks/useScopes", () => ({ useScopes: () => [] })); +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn() })); + +const fakeWsManager = { onNodeUpdate: () => () => {} } as unknown as WsManager; + +const node = (over: Partial): NodeSummary => ({ + id: "node-a", + publicKey: "aabbccdd", + nodeType: 2, + nodeTypeName: "REPEATER", + name: "Node A", + lat: null, + lng: null, + iatas: [], + knownNeighborCount: 0, + ...over, +}); + +function mount(items: NodeSummary[]) { + vi.mocked(getNodesPage).mockResolvedValue({ items, nextCursor: null, hasMore: false }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + , + ); +} + +beforeEach(() => { + vi.mocked(getNodesPage).mockReset(); +}); + +describe("NodeTable location column", () => { + it("shows a dash for an explicit 0/0 advert reset, not the coordinates", async () => { + mount([node({ id: "node-zero", name: "Zeroed node", lat: 0, lng: 0 })]); + const nameCell = await screen.findByText("Zeroed node"); + const row = nameCell.closest("tr")!; + const cells = within(row).getAllByRole("cell"); + expect(cells[cells.length - 1]).toHaveTextContent("—"); + expect(screen.queryByText("0.00, 0.00")).not.toBeInTheDocument(); + }); + + it("still shows coordinates when only one axis is zero", async () => { + mount([node({ id: "node-partial", name: "Partial node", lat: 0, lng: 10 })]); + await screen.findByText("Partial node"); + expect(screen.getByText("0.00, 10.00")).toBeInTheDocument(); + }); +}); + +describe("NodeTable IATA badge tooltip", () => { + it("shows a translated last-heard label, in English and French", async () => { + const lastHeard = Date.now() - 7 * 86_400_000; + mount([node({ id: "node-iata", name: "IATA node", iatas: [{ iata: "YOW", lastHeard }] })]); + const badge = await screen.findByText("YOW"); + const trigger = badge.parentElement!; + + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toHaveTextContent("last heard 7d ago"); + fireEvent.mouseLeave(trigger); + + await i18n.changeLanguage("fr"); + fireEvent.mouseEnter(trigger); + expect(screen.getByRole("tooltip")).toHaveTextContent("dernier contact il y a 7 j"); + }); +}); diff --git a/tests/features/observers/ObserverDetailPanel.test.tsx b/tests/features/observers/ObserverDetailPanel.test.tsx index 4e7c7b7f..2b710a2d 100644 --- a/tests/features/observers/ObserverDetailPanel.test.tsx +++ b/tests/features/observers/ObserverDetailPanel.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, it, vi, beforeEach } from "vitest"; +import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; @@ -76,6 +76,34 @@ describe("ObserverDetailPanel status", () => { }); }); +describe("ObserverDetailPanel copy link", () => { + const writeText = vi.fn(); + + beforeEach(() => { + Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); + writeText.mockClear(); + }); + + afterEach(() => window.history.replaceState({}, "", "/")); + + it("copies a link scoped to the observer tab, dropping foreign params", async () => { + window.history.replaceState({}, "", "/?tab=Packets&hash=abc&analyze=1&observation=7&iata=YVR"); + + renderPanel(); + + await screen.findByText("online"); + fireEvent.click(screen.getByRole("button", { name: "Copy observer link" })); + + const url = new URL(writeText.mock.calls[0][0]); + expect(url.searchParams.get("tab")).toBe("Observers"); + expect(url.searchParams.get("observer")).toBe("obs-1"); + expect(url.searchParams.get("iata")).toBe("YVR"); + expect(url.searchParams.has("hash")).toBe(false); + expect(url.searchParams.has("analyze")).toBe(false); + expect(url.searchParams.has("observation")).toBe(false); + }); +}); + describe("ObserverDetailPanel adverts", () => { it("lists adverts heard by the observer", async () => { mockGetObserverAdverts.mockResolvedValue({ diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx index d2a0496a..6f0f9409 100644 --- a/tests/features/observers/ObserverPage.test.tsx +++ b/tests/features/observers/ObserverPage.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi } from "vitest"; import "../../../src/i18n"; import { useState } from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, within } from "@testing-library/react"; import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; import type { WsManager } from "../../../src/api/ws-manager"; import { ObserverPage } from "../../../src/features/observers/ObserverPage"; @@ -77,7 +77,10 @@ describe("Observer destination", () => { it("restores a deep link and has a directory return without prior history", async () => { view("?tab=Observers&observer=observer-b&range=30d"); expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); - expect(screen.getAllByRole("option").map(option => option.getAttribute("value"))).toEqual(["24h", "7d", "30d"]); + const range = screen.getByRole("group", { name: "Time range" }); + const options = within(range).getAllByRole("button"); + expect(options.map(option => option.textContent)).toEqual(["24h", "7d", "30d"]); + expect(options[2]).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); expect(screen.getByLabelText("Directory search")).toBeVisible(); }); diff --git a/tests/features/observers/ObserverPicker.test.tsx b/tests/features/observers/ObserverPicker.test.tsx new file mode 100644 index 00000000..dc0003eb --- /dev/null +++ b/tests/features/observers/ObserverPicker.test.tsx @@ -0,0 +1,40 @@ +import { it, expect, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverPicker } from "../../../src/features/observers/ObserverPicker"; +import { getObserversPage } from "../../../src/api/client"; + +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/api/client", () => ({ getObserversPage: vi.fn(async () => ({ items: [], hasMore: false })) })); + +beforeEach(() => { vi.mocked(getObserversPage).mockClear(); }); + +function view(id: string, name: string) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +it("shows the placeholder name, not the dropdown's default All label, before a partner is chosen", () => { + view("", "Choose an observer to compare…"); + const trigger = screen.getByRole("button", { name: /Choose an observer to compare…/ }); + expect(trigger).not.toHaveTextContent("All"); +}); + +it("keeps a trailing space visible while typing and trims only the query sent to the server", async () => { + vi.useFakeTimers(); + try { + view("", "Choose an observer"); + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "Node " } }); + await act(async () => { vi.advanceTimersByTime(400); }); + expect(input).toHaveValue("Node "); + expect(getObserversPage).toHaveBeenCalledWith(["YOW"], { name: "Node", limit: 50 }); + } finally { + vi.useRealTimers(); + } +}); diff --git a/tests/features/observers/ObserverSummary.compact.test.tsx b/tests/features/observers/ObserverSummary.compact.test.tsx index 189f95f7..254a8c77 100644 --- a/tests/features/observers/ObserverSummary.compact.test.tsx +++ b/tests/features/observers/ObserverSummary.compact.test.tsx @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { render, screen } from "@testing-library/react"; import { ObserverSummary } from "../../../src/features/observers/ObserverSummary"; import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types"; const observer = { id: "observer", displayName: "Roof", iata: "YKF", brokers: [], lastStatusAt: Date.now() - 600_000 } as unknown as Observer; @@ -19,3 +20,23 @@ describe("compact observer header", () => { expect(screen.getByRole("img", { name: "No status report" })).toHaveClass("text-text-muted"); }); }); + +describe("observer summary card notes", () => { + it("wraps the last-complete-hour window under its card instead of a non-wrapping sublabel", () => { + const activity = { summary: { + recordedPackets: 5, lastCompleteHour: 3, lastCompleteHourStart: 0, lastCompleteHourEnd: 3_600_000, latestRecordedAt: null, + } } as unknown as ObserverActivity; + render(); + const note = screen.getByText("00:00–01:00 UTC"); + expect(note).not.toHaveClass("whitespace-nowrap"); + expect(note).toHaveClass("mt-1"); + }); + + it("wraps the noise-floor telemetry note under its card the same way", () => { + const points = [{ noiseFloorDb: -91 } as unknown as TelemetryPoint]; + render(); + const note = screen.getByText("Latest telemetry interval"); + expect(note).not.toHaveClass("whitespace-nowrap"); + expect(note).toHaveClass("mt-1"); + }); +}); diff --git a/tests/features/observers/observer-id.test.ts b/tests/features/observers/observer-id.test.ts new file mode 100644 index 00000000..32cb672a --- /dev/null +++ b/tests/features/observers/observer-id.test.ts @@ -0,0 +1,14 @@ +import { describe, it, expect } from "vitest"; +import { OBSERVER_UUID } from "../../../src/features/observers/observer-id"; + +describe("OBSERVER_UUID", () => { + it("matches a well-formed UUID, case-insensitively", () => { + expect(OBSERVER_UUID.test("3fa85f64-5717-4562-b3fc-2c963f66afa6")).toBe(true); + expect(OBSERVER_UUID.test("3FA85F64-5717-4562-B3FC-2C963F66AFA6")).toBe(true); + }); + + it("rejects the nil UUID and malformed strings", () => { + expect(OBSERVER_UUID.test("00000000-0000-0000-0000-000000000000")).toBe(false); + expect(OBSERVER_UUID.test("not-a-uuid")).toBe(false); + }); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts index de849cb7..05cfa9d8 100644 --- a/tests/features/observers/observer-navigation.test.ts +++ b/tests/features/observers/observer-navigation.test.ts @@ -19,6 +19,13 @@ describe("observerDestination", () => { const p = observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"), "b"); expect(p.toString()).toBe("tab=Observers&observer=b&range=7d"); }); + + it("drops the analytics compare params so they don't ride along into the dashboard URL", () => { + const p = observerDestination(new URLSearchParams("compareA=x&compareB=y&compareSince=1"), "b"); + expect(p.has("compareA")).toBe(false); + expect(p.has("compareB")).toBe(false); + expect(p.has("compareSince")).toBe(false); + }); }); describe("observerRange", () => { diff --git a/tests/features/observers/observer-stats.test.ts b/tests/features/observers/observer-stats.test.ts new file mode 100644 index 00000000..1f793415 --- /dev/null +++ b/tests/features/observers/observer-stats.test.ts @@ -0,0 +1,31 @@ +import { describe, it, expect } from "vitest"; +import { getStats, observerNoiseFloor } from "../../../src/features/observers/observer-stats"; + +describe("getStats", () => { + it("returns null when metadata is missing", () => { + expect(getStats(undefined)).toBeNull(); + }); + + it("returns null when stats is not an object", () => { + expect(getStats({ stats: "nope" })).toBeNull(); + }); + + it("returns the stats object when present", () => { + expect(getStats({ stats: { noise_floor: -95, queue_len: 3 } })).toEqual({ noise_floor: -95, queue_len: 3 }); + }); +}); + +describe("observerNoiseFloor", () => { + it("returns null when statusMetadata is missing", () => { + expect(observerNoiseFloor({ statusMetadata: undefined })).toBeNull(); + }); + + it("returns null when noise_floor is not a finite number", () => { + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: NaN } } })).toBeNull(); + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: "cold" } } })).toBeNull(); + }); + + it("returns the noise floor when it is a finite number", () => { + expect(observerNoiseFloor({ statusMetadata: { stats: { noise_floor: -102.5 } } })).toBe(-102.5); + }); +}); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 09ad7c5f..53cc440a 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, it, expect, vi } from "vitest"; -import { fireEvent, render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; +import i18n from "../../../src/i18n"; import type { LatestObserver, PacketSummary } from "../../../src/types/api"; const pkt = (observer?: LatestObserver): PacketSummary => ({ @@ -72,7 +73,18 @@ describe("PacketEndpoints", () => { render(); - expect(screen.getByText("?")).toBeInTheDocument(); + const chip = screen.getByText("?"); + expect(chip).toBeInTheDocument(); + expect(chip).toHaveAttribute("title", "No path resolution available"); + }); + + it("translates the no-resolution title to French", async () => { + await act(() => i18n.changeLanguage("fr")); + render(); + expect(screen.getByText("?")).toHaveAttribute("title", "Aucune résolution de chemin disponible"); + await act(() => i18n.changeLanguage("en")); }); it("shows an advert as its single source node with no destination", () => { diff --git a/tests/features/packets/PathData.test.tsx b/tests/features/packets/PathData.test.tsx index 7ddc96cd..636b2fa9 100644 --- a/tests/features/packets/PathData.test.tsx +++ b/tests/features/packets/PathData.test.tsx @@ -1,6 +1,7 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, act } from "@testing-library/react"; import { ResolvedHopBlock } from "../../../src/features/packets/PathData"; +import i18n from "../../../src/i18n"; import type { ResolvedHop } from "../../../src/types/api"; // mobile/touch == no hover-capable pointer; desktop == has hover. Interaction modality keys off @@ -49,6 +50,26 @@ describe("ResolvedHopBlock tooltip", () => { }); }); +describe("ResolvedHopBlock unresolved popover", () => { + const noMatchHop: ResolvedHop = { confidence: "none", nodes: [] }; + + it("shows the same no-resolution message PacketEndpoints uses", () => { + setMobile(true); + render(); + fireEvent.click(screen.getByText("ABC1")); + expect(screen.getByRole("tooltip")).toHaveTextContent("No path resolution available"); + }); + + it("translates it to French", async () => { + setMobile(true); + await act(() => i18n.changeLanguage("fr")); + render(); + fireEvent.click(screen.getByText("ABC1")); + expect(screen.getByRole("tooltip")).toHaveTextContent("Aucune résolution de chemin disponible"); + await act(() => i18n.changeLanguage("en")); + }); +}); + describe("ResolvedHopBlock (desktop)", () => { it("opens the node directly when the single-match block is clicked", () => { setMobile(false); diff --git a/tests/features/routes/RouteEvidencePanel.test.tsx b/tests/features/routes/RouteEvidencePanel.test.tsx index 94bd7372..baf6df2d 100644 --- a/tests/features/routes/RouteEvidencePanel.test.tsx +++ b/tests/features/routes/RouteEvidencePanel.test.tsx @@ -106,4 +106,10 @@ describe("retained route evidence", () => { fireEvent.click(screen.getByRole("button", { name: "Retry" })); await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument()); }); + it("colours a good SNR report the same way every other SNR cell does", async () => { + vi.mocked(getRouteEvidence).mockResolvedValue({ ...page, items: [{ ...page.items[0]!, snr: 12 }], hasMore: false }); + mount(); + const snr = await screen.findByText("12.00"); + expect(snr).toHaveClass("text-green"); + }); }); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index f1172333..cb1389a9 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -1,10 +1,10 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { RouteTable } from "../../../src/features/routes/RouteTable"; -import { MemoryRouter } from "react-router-dom"; -import { RegionProvider } from "../../../src/hooks/useRegion"; +import { MemoryRouter, useLocation } from "react-router-dom"; +import { RegionProvider, useRegionSelection } from "../../../src/hooks/useRegion"; import { ALL_REGIONS } from "../../../src/hooks/region-selection"; import { getKnownRoutesPage, @@ -145,6 +145,42 @@ describe("RouteTable search", () => { }); }); +function LocationKeyProbe({ keys }: { keys: string[] }) { + const location = useLocation(); + useEffect(() => { + if (keys[keys.length - 1] !== location.key) keys.push(location.key); + }, [location.key, keys]); + return null; +} + +function ChangeRegionButton() { + const { setSelection } = useRegionSelection(); + return ; +} + +it("does not navigate on a region change with no route selected", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const keys: string[] = []; + render( + + + + + + + + + , + ); + await screen.findByText("Find path"); + const initialKeyCount = keys.length; + + fireEvent.click(screen.getByRole("button", { name: "Change region" })); + + await waitFor(() => expect(mockGetKnownRoutesPage).toHaveBeenCalledWith(expect.objectContaining({ iata: "AAA" }))); + expect(keys.length).toBe(initialKeyCount); +}); + it("preserves a shared saved route while a named region resolves", async () => { vi.mocked(getRegions).mockResolvedValue([{ id: 1, slug: "ontario", name: "Ontario" }]); vi.mocked(getRegion).mockResolvedValue({ id: 1, slug: "ontario", name: "Ontario", iatas: ["YOW"] }); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index 3fc0dafc..c04ca526 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { act, render, screen, fireEvent, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MeshTab } from "../../../src/features/stats/MeshTab"; +import i18n from "../../../src/i18n"; import { getStatsOverview, getStatsObservations, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; import type { WsManager } from "../../../src/api/ws-manager"; import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; @@ -163,3 +164,18 @@ it("keeps the scope list collapsed until requested", async () => { expect(scopeList).toHaveAttribute("open"); expect(within(scopeList).getByText("#old")).toBeVisible(); }); + +it("shows French labels for the KPIs, chart titles and scope table", async () => { + await act(() => i18n.changeLanguage("fr")); + mount(); await loaded(); + expect(within(card("Paquets totaux")).getByText("111")).toBeInTheDocument(); + expect(screen.getByText("Observations · 24h")).toBeInTheDocument(); + expect(screen.getByText("Meilleurs nœuds · historique complet")).toBeInTheDocument(); + const typesChart = within(card("Types de nœuds · historique complet")).getByTestId("chart"); + expect(typesChart.textContent).toContain("NŒUDS"); + fireEvent.click(screen.getByText("Scopes", { exact: true })); + const scopeList = screen.getByText("Scopes", { exact: true }).closest("details")!; + expect(within(scopeList).getByText("Scope")).toBeInTheDocument(); + expect(within(scopeList).getByText("Nœuds")).toBeInTheDocument(); + await act(() => i18n.changeLanguage("en")); +}); diff --git a/tests/features/stats/useLiveStats.test.tsx b/tests/features/stats/useLiveStats.test.tsx index 45a49094..9c0f64b9 100644 --- a/tests/features/stats/useLiveStats.test.tsx +++ b/tests/features/stats/useLiveStats.test.tsx @@ -65,6 +65,25 @@ describe("useLiveOverview", () => { expect(after?.totalPackets).toBe(101); expect(after?.totalObservations).toBe(202); }); + + it("drops pending deltas instead of resurrecting a failed overview query", () => { + const qc = new QueryClient(); + qc.setQueryData(["stats-overview", "YOW"], overview); + qc.getQueryCache().find({ queryKey: ["stats-overview", "YOW"] })?.setState({ status: "error", error: new Error("refetch failed") }); + const { manager, emit } = fakeManager(); + + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + renderHook(() => useLiveOverview(manager), { wrapper }); + + emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]); + rafCallbacks.forEach((cb) => cb(0)); + + const state = qc.getQueryState(["stats-overview", "YOW"]); + expect(state?.status).toBe("error"); + expect(state?.data).toEqual(overview); + }); }); describe("useLiveObserver", () => { diff --git a/tests/i18n/index.test.ts b/tests/i18n/index.test.ts index 77cd557c..e60acc75 100644 --- a/tests/i18n/index.test.ts +++ b/tests/i18n/index.test.ts @@ -27,13 +27,41 @@ describe("language preferences and catalogs", () => { }); it("still changes language when browser storage is unavailable", async () => { - vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { throw new Error("blocked"); }); - vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("blocked"); }); - expect(readLanguagePreference()).toBe("en"); - await i18n.changeLanguage("fr"); - expect(i18n.t("tabs.Packets")).toBe("Paquets"); - expect(document.documentElement.lang).toBe("fr"); - expect(document.documentElement.dir).toBe("ltr"); + vi.stubGlobal("localStorage", { + getItem() { throw new Error("blocked"); }, + setItem() { throw new Error("blocked"); }, + removeItem() {}, + }); + try { + expect(readLanguagePreference()).toBe("en"); + await i18n.changeLanguage("fr"); + expect(i18n.t("tabs.Packets")).toBe("Paquets"); + expect(document.documentElement.lang).toBe("fr"); + expect(document.documentElement.dir).toBe("ltr"); + } finally { + vi.unstubAllGlobals(); + } + }); + + it("does not persist a language on module init, only on an explicit change", async () => { + // Isolated in-memory store: the real localStorage is a single process-wide object (Node's + // localStorage shadows jsdom's per test file), so a leftover "beacon-language" from another + // suite sharing this worker could otherwise land here before the dynamic import reads it. + const store = new Map(); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + removeItem: (key: string) => store.delete(key), + }); + try { + vi.resetModules(); + const fresh = await import("../../src/i18n"); + expect(localStorage.getItem("beacon-language")).toBeNull(); + await fresh.default.changeLanguage("fr"); + expect(localStorage.getItem("beacon-language")).toBe("fr"); + } finally { + vi.unstubAllGlobals(); + } }); it("persists only the language preference and restores English document metadata", async () => { @@ -59,6 +87,20 @@ describe("language preferences and catalogs", () => { expect(i18n.t("region.count", { lng: "en", count: 2 })).toBe("2 regions"); expect(i18n.t("region.count", { lng: "fr", count: 1 })).toBe("1 région"); expect(i18n.t("region.count", { lng: "fr", count: 2 })).toBe("2 régions"); - expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("RÉESSAI DANS 5 s"); + expect(i18n.t("connection.rateLimited", { lng: "fr", seconds: 5 })).toBe("DÉBIT LIMITÉ 5 s"); + }); + + it("has whole-phrase battery/noise labels in both catalogs (no joined-word strings)", () => { + expect(i18n.t("observerPage.batteryV", { lng: "en" })).toBe("Battery V"); + expect(i18n.t("observerPage.noiseDbm", { lng: "en" })).toBe("Noise dBm"); + expect(i18n.t("observerPage.batteryV", { lng: "fr" })).toBe("Batterie V"); + expect(i18n.t("observerPage.noiseDbm", { lng: "fr" })).toBe("Bruit dBm"); + }); + + it("interpolates the payload-type total as a plain value, not a plural count", () => { + // formatCount can return a non-numeric string like "1.2k"; a `count` placeholder would feed that + // into plural resolution instead of a straight interpolation. + expect(i18n.t("mesh.obs", { lng: "en", value: "1.2k" })).toBe("1.2k obs"); + expect(i18n.t("mesh.obs", { lng: "fr", value: "1,2k" })).toBe("1,2k obs"); }); }); diff --git a/tests/lib/formatters.test.ts b/tests/lib/formatters.test.ts index 1064263d..844f71af 100644 --- a/tests/lib/formatters.test.ts +++ b/tests/lib/formatters.test.ts @@ -4,7 +4,9 @@ import { formatRadioParts, formatHex, formatAbsolute, + formatUtc, timeAgoMs, + timeAgoParts, formatSnr, snrLevel, formatPropagation, @@ -38,6 +40,26 @@ describe("formatAbsolute", () => { }); }); +describe("formatUtc", () => { + const t = 3_665_000; // 1970-01-01 01:01:05 UTC + + it("formats as YYYY-MM-DD HH:MM by default", () => { + expect(formatUtc(t)).toBe("1970-01-01 01:01"); + }); + + it("appends :SS when seconds is requested", () => { + expect(formatUtc(t, { seconds: true })).toBe("1970-01-01 01:01:05"); + }); + + it("shows only HH:MM when timeOnly is requested", () => { + expect(formatUtc(t, { timeOnly: true })).toBe("01:01"); + }); + + it("shows HH:MM:SS when both timeOnly and seconds are requested", () => { + expect(formatUtc(t, { timeOnly: true, seconds: true })).toBe("01:01:05"); + }); +}); + describe("timeAgoMs", () => { it("renders sub-minute as seconds and minutes/hours/days above that", () => { const now = Date.now(); @@ -52,6 +74,20 @@ describe("timeAgoMs", () => { }); }); +describe("timeAgoParts", () => { + it("splits the same clamping/flooring timeAgoMs uses into a count and unit", () => { + const now = Date.now(); + expect(timeAgoParts(now - 5_000)).toEqual({ count: 5, unit: "s" }); + expect(timeAgoParts(now - 5 * 60_000)).toEqual({ count: 5, unit: "m" }); + expect(timeAgoParts(now - 3 * 3_600_000)).toEqual({ count: 3, unit: "h" }); + expect(timeAgoParts(now - 2 * 86_400_000)).toEqual({ count: 2, unit: "d" }); + }); + + it("clamps future timestamps (clock skew) to 0s", () => { + expect(timeAgoParts(Date.now() + 60_000)).toEqual({ count: 0, unit: "s" }); + }); +}); + describe("snrLevel", () => { it("returns good for SNR >= 10", () => { expect(snrLevel(10.5)).toBe("good"); diff --git a/tests/setup.ts b/tests/setup.ts index f889c4a3..315c9a7c 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -21,7 +21,25 @@ if (!window.matchMedia) { })); } +// Node 26 exposes `localStorage` as undefined without `--localstorage-file`, shadowing jsdom's. +if (!globalThis.localStorage) { + const store = new Map(); + Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + key: (index: number) => Array.from(store.keys())[index] ?? null, + get length() { return store.size; }, + }, + configurable: true, + writable: true, + }); +} + afterEach(async () => { cleanup(); await i18n.changeLanguage("en"); + try { localStorage.clear(); } catch { /* stubbed per-test */ } }); From 00d859d9a2e5e104c0950b36c57e097061d6303d Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:29:35 -0400 Subject: [PATCH 119/163] fix(nodes): clear a node's position when nodeUpdate sends null (#103) The server now nulls a location on an explicit 0/0 advert and the WS nodeUpdate event mirrors that: omitted lat/lng keeps the current position, null clears it. patchNodeSummary used ?? so a clear was ignored. --- src/api/client.ts | 2 +- src/features/nodes/node-updates.ts | 4 ++-- src/types/ws.ts | 6 +++--- tests/features/nodes/node-updates.test.ts | 19 +++++++++++++++++++ 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/src/api/client.ts b/src/api/client.ts index bfdd2fed..e375f6cf 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -233,7 +233,7 @@ export function getTraceDetail(tag: string): Promise { export async function getObserver(observerId: string): Promise { const observer = await request(`/observers/${observerId}`); - // The server marshals the stored JSON byte slice as base64; decode it, and accept an object once json.RawMessage ships. + // Servers before json.RawMessage (and cache entries written by them) send the stored JSON as base64. let metadata: unknown = observer.statusMetadata; if (typeof metadata === "string") { try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } diff --git a/src/features/nodes/node-updates.ts b/src/features/nodes/node-updates.ts index 138ef5cf..578c00f0 100644 --- a/src/features/nodes/node-updates.ts +++ b/src/features/nodes/node-updates.ts @@ -18,8 +18,8 @@ export function patchNodeSummary( // only name/coords move in practice; nodeType/iatas are near-static, so we drop data.nodeType here // and let a reload carry a rare type change rather than keep a numeric-type lookup in sync const name = data.name || prev.name; - const lat = data.lat ?? prev.lat; - const lng = data.lng ?? prev.lng; + const lat = data.lat === undefined ? prev.lat : data.lat; + const lng = data.lng === undefined ? prev.lng : data.lng; const possiblyForeign = data.possiblyForeign === undefined ? prev.possiblyForeign : data.possiblyForeign ?? undefined; // a re-advert that re-sends the same values must keep the SAME ref so patchInfinitePages no-ops // (otherwise an unchanged node would trigger a full map FeatureCollection rebuild + setData) diff --git a/src/types/ws.ts b/src/types/ws.ts index eafdced8..bd933550 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -110,9 +110,9 @@ export interface WsNodeUpdate { // Omitted leaves the prior verdict unchanged; null clears an unknown/reset position. possiblyForeign?: boolean | null; iata: string; - // decimal degrees, same as REST /nodes (api/nodes.go serializes *float64 degrees to both) - lat?: number; - lng?: number; + // decimal degrees as in REST /nodes; omitted keeps the prior position, null clears it (explicit 0/0 advert) + lat?: number | null; + lng?: number | null; isObserver: boolean; iatas: NodeIATA[]; defaultScope?: string; diff --git a/tests/features/nodes/node-updates.test.ts b/tests/features/nodes/node-updates.test.ts index 93013dea..bc42afb3 100644 --- a/tests/features/nodes/node-updates.test.ts +++ b/tests/features/nodes/node-updates.test.ts @@ -68,6 +68,19 @@ describe("patchNodeSummary", () => { expect(out[0]!.lng).toBe(20); }); + it("clears the position when the update sends explicit null", () => { + const list = [node({ id: "a", lat: 10, lng: 20 })]; + const out = patchNodeSummary(list, update({ nodeId: "a", lat: null, lng: null }))!; + expect(out).not.toBe(list); + expect(out[0]!.lat).toBeNull(); + expect(out[0]!.lng).toBeNull(); + }); + + it("returns the same ref when a null clear hits a node that is already unlocated", () => { + const list = [node({ id: "a", name: "Keep", lat: null, lng: null })]; + expect(patchNodeSummary(list, update({ nodeId: "a", name: "Keep", lat: null, lng: null }))).toBe(list); + }); + it("returns the same list ref when the update changes nothing (no needless repaint)", () => { const list = [node({ id: "a", name: "Keep", lat: 10, lng: 20 })]; // re-advert with identical name + omitted coords: every field resolves back to the prev value @@ -114,4 +127,10 @@ describe("upsertNodePages", () => { it("passes undefined caches through", () => { expect(upsertNodePages(undefined, update({}))).toBeUndefined(); }); + + it("inserts a fresh node with an explicit null position", () => { + const old = pages([node({ id: "a" })]); + const out = upsertNodePages(old, update({ nodeId: "new1", lat: null, lng: null }))!; + expect(out.pages[0]!.items[1]).toMatchObject({ id: "new1", lat: null, lng: null }); + }); }); From 8d1f30d5534ed3b434ccab9d810123e22eac0c10 Mon Sep 17 00:00:00 2001 From: MrAlders0n <55921894+MrAlders0n@users.noreply.github.com> Date: Wed, 30 Sep 2026 13:24:54 -0400 Subject: [PATCH 120/163] Bring back the Analytics observer tab and tidy analytics (#104) --- src/App.tsx | 8 +- src/components/AppShell.tsx | 161 ++++++++++++------ src/components/InfoTip.tsx | 23 +++ src/components/LanguagePicker.tsx | 45 +++-- src/components/Tooltip.tsx | 6 +- src/features/observers/ObserverComparison.tsx | 7 +- src/features/observers/ObserverPicker.tsx | 117 ++++++++++--- src/features/observers/ObserverSummary.tsx | 11 +- src/features/stats/CompareObserversTab.tsx | 67 +++----- src/features/stats/MeshTab.tsx | 2 +- src/features/stats/NeighbourGraphTab.tsx | 8 +- src/features/stats/ObserverAnalyticsTab.tsx | 128 ++++++++++++++ src/features/stats/ObserverTab.tsx | 5 +- src/features/stats/PathsTab.tsx | 13 +- src/features/stats/ScopesTab.tsx | 7 +- src/features/stats/SignalTab.tsx | 13 +- src/features/stats/StatsOverview.tsx | 20 +-- src/features/stats/StatsSubHeader.tsx | 10 ++ src/features/stats/TalkersTab.tsx | 2 +- src/features/stats/TrafficTab.tsx | 7 +- src/features/stats/cards.tsx | 2 +- src/features/stats/types.ts | 2 +- src/i18n/locales/en.json | 10 +- src/i18n/locales/fr.json | 10 +- tests/App.observerInvestigation.test.tsx | 21 ++- tests/components/AppShell.test.tsx | 20 +++ tests/components/InfoTip.test.tsx | 21 +++ .../observers/ObserverComparison.test.tsx | 2 +- .../observers/ObserverPicker.test.tsx | 16 +- .../stats/CompareObserversTab.test.tsx | 37 ++-- tests/features/stats/ScopesTab.test.tsx | 3 +- tests/features/stats/StatsSubHeader.test.tsx | 5 +- tests/features/stats/TrafficTab.test.tsx | 4 + .../stats/analytics-navigation.test.tsx | 28 ++- 34 files changed, 614 insertions(+), 227 deletions(-) create mode 100644 src/components/InfoTip.tsx create mode 100644 src/features/stats/ObserverAnalyticsTab.tsx create mode 100644 tests/components/InfoTip.test.tsx diff --git a/src/App.tsx b/src/App.tsx index 02c5975d..deeac026 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -160,8 +160,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // unknown ?tab value falls back to Packets instead of rendering a blank pane. // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); - const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer"; - const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets"); + const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS.includes("Packets") ? "Packets" : ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -170,9 +169,6 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: const analyzerHash = searchParams.get("analyze") === "1" ? searchParams.get("hash") : null; const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); - useEffect(() => { - if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true }); - }, [legacyObserver, searchParams, setSearchParams]); const [panels, setPanels] = useState([]); const scene = JSON.stringify([activeTab, searchParams.get("route"), searchParams.get("hash"), searchParams.get("node"), searchParams.get("observer"), searchParams.get("analyze")]); const [panelScene, setPanelScene] = useState(scene); @@ -319,7 +315,7 @@ function AppInner({ observerVisit, onObserverDashboard, onReturn, onExitVisit }: // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: { if (hash) viewPacket(hash); }} onViewNode={viewNode} />, Channels: , - Analytics: , + Analytics: , Map: , }; diff --git a/src/components/AppShell.tsx b/src/components/AppShell.tsx index 77450882..6e72c43d 100644 --- a/src/components/AppShell.tsx +++ b/src/components/AppShell.tsx @@ -10,7 +10,8 @@ import { useRateLimit } from "../hooks/useRateLimit"; import { useTheme } from "../hooks/useTheme"; import { Dropdown } from "./Dropdown"; import { BottomNav } from "./BottomNav"; -import { LanguagePicker } from "./LanguagePicker"; +import { LanguageOptions, LanguagePicker } from "./LanguagePicker"; +import { useIsMobile } from "../hooks/useMediaQuery"; import { BeaconWordmark } from "./BeaconWordmark"; import { getIatas } from "../api/client"; import { ENABLED_TABS, ENABLED_THEME_IDS, selectableThemes, APP_NAME, GITHUB_URL } from "../lib/constants"; @@ -18,7 +19,7 @@ import type { WsManager } from "../api/ws-manager"; // header widgets: WS status, region picker, theme picker -function LiveBadge({ wsManager }: { wsManager: WsManager }) { +function LiveBadge({ wsManager, compact = false }: { wsManager: WsManager; compact?: boolean }) { const { t } = useTranslation(); const { status } = useWsStatus(wsManager); const [staleStr, setStaleStr] = useState(""); @@ -35,6 +36,11 @@ function LiveBadge({ wsManager }: { wsManager: WsManager }) { }, [status, wsManager]); if (status === "connected") { + if (compact) return ( + + + + ); return (
@@ -306,9 +312,8 @@ function RegionSelectorPanel() { function ThemePicker() { const { t } = useTranslation(); - const { themeId, themes, setThemeId } = useTheme(); + const { themeId, themes } = useTheme(); const current = themes.find((t) => t.id === themeId); - const list = selectableThemes(themes, ENABLED_THEME_IDS); return ( ▾ )} + > + {(close) => } + + ); +} + +function ThemeOptions({ onPick }: { onPick?: () => void }) { + const { themeId, themes, setThemeId } = useTheme(); + return ( + <> + {selectableThemes(themes, ENABLED_THEME_IDS).map((theme) => ( + + ))} + + ); +} + +function GitHubLink() { + return ( + + + + ); +} + +const MENU_GROUP = "px-3 pt-2 pb-1 text-[10px] font-mono uppercase tracking-wide text-text-dim"; + +// Phones fold theme, language and the repo link into one menu so the header stays a single row. +function SettingsMenu() { + const { t } = useTranslation(); + return ( + ( + + )} > {(close) => ( <> - {list.map((t) => ( - - ))} +
{t("language.label")}
+ +
{t("theme.label")}
+ +
+ + {APP_NAME} v{__APP_VERSION__} +
)}
@@ -367,31 +427,36 @@ interface AppShellProps { export function AppShell({ activeTab, onTabChange, wsManager, children }: AppShellProps) { const { t } = useTranslation(); + const isMobile = useIsMobile(); return (
-
- -
- - - -
-
- - - - - -
-
+ {isMobile ? ( +
+ +
+ +
+
+ + + +
+
+ ) : ( +
+ +
+ + + +
+
+ + + +
+
+ )}
)} ); } + +export function LanguageOptions({ onPick }: { onPick?: () => void }) { + const { i18n } = useTranslation(); + const language = i18n.resolvedLanguage ?? "en"; + return ( + <> + {languages.map(({ code, name }) => ( + + ))} + + ); +} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 7d6c838e..267f38be 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -4,7 +4,7 @@ import { useHasHover } from "../hooks/useMediaQuery"; // Portals to with fixed positioning so overflow parents (the data tables) can't clip it. // Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap. -export function Tooltip({ label, children, className = "" }: { label: ReactNode; children: ReactNode; className?: string }) { +export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) { const hasHover = useHasHover(); const ref = useRef(null); const tipRef = useRef(null); @@ -60,6 +60,8 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode; onMouseEnter={hasHover ? show : undefined} onMouseLeave={hasHover ? hide : undefined} onClick={hasHover ? undefined : toggle} + onFocus={hasHover ? show : undefined} + onBlur={hasHover ? hide : undefined} className={`inline-flex ${className}`} > {children} @@ -69,7 +71,7 @@ export function Tooltip({ label, children, className = "" }: { label: ReactNode; ref={tipRef} role="tooltip" style={{ left: pos.left, top: pos.top }} - className="fixed z-50 pointer-events-none whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg" + className={`fixed z-50 pointer-events-none ${wrap ? "max-w-xs whitespace-normal leading-relaxed" : "whitespace-nowrap"} rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg`} > {label} , diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx index bbfe6100..9fa407b2 100644 --- a/src/features/observers/ObserverComparison.tsx +++ b/src/features/observers/ObserverComparison.tsx @@ -1,6 +1,7 @@ import { useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; import { getObserverComparison } from "../../api/client"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { formatBattery, formatUptime } from "../../lib/formatters"; @@ -82,16 +83,14 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

{until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> -

{t("observerPage.window")}: {date(start)} – {date(end)}. {t("observerCompare.currentNote")}

+

{t("observerPage.window")}: {date(start)} – {date(end)}

{rows.map(([label, a, bValue]) => )}
{t("observerCompare.metric")}AB
{label}{a}{bValue}
- -

{t("observerCompare.retention")}

+ }> {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <>

{t("observerCompare.total", { count: overlap.data.totalPackets })}

{overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
}
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
-

{t("observerCompare.definition")}

} } diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx index c15d3e0d..c374d82b 100644 --- a/src/features/observers/ObserverPicker.tsx +++ b/src/features/observers/ObserverPicker.tsx @@ -1,33 +1,100 @@ -import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; +import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getObserversPage } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; -import { SearchBar } from "../../components/SearchBar"; -import { SelectDropdown } from "../../components/SelectDropdown"; +import { Dropdown } from "../../components/Dropdown"; -export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { +function PickerPanel({ id, onPick, excludeId, label }: { id: string; onPick: (id: string) => void; excludeId?: string; label: string }) { const { t } = useTranslation(); const { iatas, regionKey } = useRegion(); const [query, setQuery] = useState(""); - const options = useQuery({ queryKey: ["observer-picker", regionKey, query.trim()], queryFn: () => getObserversPage(iatas, { name: query.trim() || undefined, limit: 50 }), staleTime: 30_000 }); - const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); - return
-
- {}} value={query} onChange={setQuery} /> - o.id === id) ? [{ value: id, label: name }] : []), - ...rows.map(o => ({ value: o.id, label: `${o.displayName ?? o.id.slice(0, 8)} · ${o.iata}` })), - ]} - /> -
- {options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

} -
; + // debounce so the server-side lookup fires once per pause, not once per keystroke + const [q, setQ] = useState(""); + useEffect(() => { + const timer = setTimeout(() => setQ(query.trim()), 250); + return () => clearTimeout(timer); + }, [query]); + const options = useQuery({ + queryKey: ["observer-picker", regionKey, q], + queryFn: () => getObserversPage(iatas, { name: q || undefined, limit: 50 }), + staleTime: 30_000, + placeholderData: keepPreviousData, + }); + const rows = (options.data?.items ?? []).filter((o) => o.id !== excludeId); + const status = options.isPending ? t("common.loading") : options.isError ? null : rows.length === 0 ? t("observerPage.noMatches") : null; + + return ( + <> +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter" && rows[0]) { e.preventDefault(); onPick(rows[0].id); } + }} + aria-label={`${t("observerPage.sidebarSearch")} · ${label}`} + placeholder={`${t("observerPage.sidebarSearch")}…`} + className="w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-base text-text-normal placeholder:text-text-dim sm:text-[12px]" + /> +
+
+ {rows.map((o) => { + const selected = o.id === id; + return ( + + ); + })} +
+ {status &&
{status}
} + {options.isError && ( + + )} + + ); +} + +// One field that opens a searchable list; the search runs server-side across the whole region. +export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { + const { t } = useTranslation(); + const title = label ?? t("observerPage.choose"); + return ( + ( + + )} + > + {(close) => { onSelect(next); close(); }} />} + + ); } diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx index e7f5fdab..30862f78 100644 --- a/src/features/observers/ObserverSummary.tsx +++ b/src/features/observers/ObserverSummary.tsx @@ -6,6 +6,7 @@ import { useTick } from "../../hooks/useTick"; import { observerNoiseFloor } from "./observer-stats"; import { Card, StatCard } from "../stats/cards"; import { Field } from "../../components/DetailPanel"; +import { IataChip } from "../../components/IataChip"; import type { Observer } from "./types"; import type { ObserverActivity, TelemetryPoint } from "../stats/types"; @@ -26,22 +27,22 @@ export function ObserverSummary({ observer, activity, points, pending = false }: return <>

{observer.displayName ?? observer.id.slice(0, 8)}

-
+
- + - {observer.iata} + {observer.iata}
-