From df9f4a07ada314e383bf0ed4acdfe022cf6fc8bc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Thu, 11 Jun 2026 13:34:11 -0400 Subject: [PATCH 01/83] 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 64087af..22446c1 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 a30c399..96c8087 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 5b4440c..f2ec64f 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 dd8823c..05ccc54 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 284fc94..8cd36b6 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 e703520..319b6f5 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 211f8e9..3b9f641 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 8274b0c..d09650c 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 49ebda0..50624d3 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 e4f09aa..836f3db 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 158f833..f2ec34c 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 a328fde..a9909e9 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 031100e..a8d189e 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 4f11d26..92b5e9c 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 1c7fced..0000000 --- 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 02/83] 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 96c8087..c085d8a 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 1af2665..91673c1 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 6bbc983..35f0a77 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 ac3c23c..caee590 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 7d66229..2c24ff2 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 8e1fa7d..bd78f3f 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 a5793e1..0d2840b 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 d815e43..38f49a3 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 eeb5964..f4b68be 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 8d1bc8b..d070e3a 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 03/83] 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 22446c1..1ee448d 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 2c3718f..64e3957 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 04/83] 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 3baa125..cc53d78 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 05/83] 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 c085d8a..52a28d2 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 93f71a4..ffda8b4 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 5b88cf0..3283459 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 6e7d7e3..d24a131 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 0000000..61862cd --- /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 eb6fef3..a0a69a7 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 0000000..389a0aa --- /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 1f7c6a6..ab6c672 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 0000000..0d44963 --- /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 aa1b44e..28faecb 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 0000000..8d51b4d --- /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 2c24ff2..a748f44 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 074b797..b53b86e 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 c998254..e36e3cc 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 3a0590a..bbff970 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 a230a50..913b62c 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 0caf41a..27eee62 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 0000000..8826041 --- /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 3154ef2..33f45ba 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 f84c905..192829a 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 06/83] 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 389a0aa..57d82c2 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 ab6c672..e6dc191 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 8d51b4d..ffc99c4 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 8826041..9baebc1 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 07/83] 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 57d82c2..798d239 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 e6dc191..1cafc30 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 ffc99c4..2e32cbe 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 9baebc1..d8e6ea0 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 08/83] =?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 798d239..23208ca 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 1cafc30..b4009ca 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 545811d..951479c 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 2e32cbe..2c4d3cd 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 d8e6ea0..05155a7 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 09/83] 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 b4009ca..c96b184 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 2c4d3cd..92e5a00 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 10/83] 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 d24a131..50c32dd 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 c96b184..0237143 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 11/83] 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 0237143..2cda43d 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 12/83] 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 92e5a00..498babf 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 13/83] 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 ecb8baf..427dbfa 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 91673c1..7dba588 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 14/83] 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 50c32dd..058d432 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 a0a69a7..6929b16 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 951479c..c4a7807 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 498babf..b4e815f 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 27eee62..10e356f 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 15/83] 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 3283459..b9313d7 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 058d432..2160b5b 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 6929b16..e6452d6 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 0d44963..a2163ce 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 10e356f..79a7de4 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 16/83] 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 afb2274..b07947f 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 5f8898f..477c08a 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 0000000..c19557c --- /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 a2163ce..d6db109 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 71b9f27..2c2abcc 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 f2ec64f..47a5585 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 0000000..09ab8ec --- /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 0000000..1726486 --- /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 bd78f3f..9397ada 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 0d2840b..e3dd448 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 7e6a2f5..745c9e5 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 319b6f5..b41cf57 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 0000000..4179905 --- /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 d09650c..a517233 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 3d2fa49..7c8f15d 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 fe2c1bd..3ae3aed 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 0000000..46d6682 --- /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 17/83] 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 09ab8ec..3f703d0 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 1726486..77d62ea 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 4179905..5ad8b89 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 46d6682..363d045 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 18/83] 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 00baa1b..ee905ac 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 e3dd448..14e361a 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 19/83] 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 14e361a..7853f9a 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 20/83] 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 28faecb..e720d72 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 77d62ea..0b89d0c 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 5ad8b89..4f4813b 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 dcd45e9..3184c12 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 363d045..cd63512 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 21/83] 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 3f703d0..4127998 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 0b89d0c..952566b 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 4f4813b..6586c4f 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 cd63512..f3251f1 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 22/83] 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 0000000..cea585f --- /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 7dba588..57a62e0 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 0000000..3d5fb4a --- /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 23/83] 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 b07947f..7014c18 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 0000000..f074b8e --- /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 427dbfa..02e0823 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 57a62e0..a7da6cc 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 3c33bab..c935f2f 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 3008989..e6d6fec 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 0000000..a3311d6 --- /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 0000000..fadc268 --- /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 24/83] 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 ea6449b..1806c4c 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 b844b89..4cf2325 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 adc707a..69075ed 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 732972e..32f0f0a 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 f449012..840b255 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 29a089c..21a161f 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 7014c18..73637fb 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 07205a1..bbaab79 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 ddbd3b8..ec10bf8 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 0000000..311416f --- /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 0000000..b48fdf3 --- /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 8e18df3..09ed12a 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 38/83] 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 65573b6..8f347b0 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 39/83] 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 65f25b4..463e4d3 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 8f347b0..87d6cea 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 751fb35..3209c25 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 3ce5532..9715fdb 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 f822762..cf8b878 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 311416f..c626adc 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 b48fdf3..79514eb 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 40/83] 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 3209c25..69619be 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 41/83] 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 9715fdb..13b4f8a 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 4301cc5..3ccaac2 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 e36e3cc..87902eb 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 f4b68be..86dfa73 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 bbff970..ea65403 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 79514eb..053f7ab 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 09ed12a..bbe4255 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 192829a..f759115 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 42/83] 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 116ca58..6cac33a 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 47a5585..3a1bbf9 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 9397ada..b9c689b 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 7853f9a..9917d31 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 0000000..e427ecb --- /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 a517233..6bf24fc 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 836f3db..9f61c0b 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 0cdb141..0a9e127 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 43/83] 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 6cac33a..39a9f1f 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 d93b475..24d5dfd 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 35f0a77..dbfc0ed 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 0a9e127..d950b08 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 44/83] 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 f3bf209..4debae6 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 a748f44..97c552e 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 6bb6fdf..e28664a 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 19162b6..22992a2 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 59a905d..d94464c 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 45/83] 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 13b4f8a..4c415e9 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 053f7ab..43860ba 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 46/83] 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 dbfc0ed..60179f7 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 0000000..aa2a3c5 --- /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 0000000..1f85434 --- /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 47/83] 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 43860ba..1c78385 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 f759115..2b695d7 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 d94464c..4e22947 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 48/83] 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 97c552e..02ca6e1 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 60c67f1..ec65713 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 3b9f641..a6d3256 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 e28664a..22aa462 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 49/83] 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 39a9f1f..794ccce 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 e563c10..351f9ac 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 5645a6c..e1bb284 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 d950b08..f59f676 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 1f40e28..1283b5f 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 43e4593..88b19a1 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 50/83] 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 3a1bbf9..47a5585 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 e427ecb..5da1dc2 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 51/83] 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 5da1dc2..3f50d4e 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 52/83] 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 3f50d4e..c01516c 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 8cd36b6..9b0e840 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 a8d189e..6a4784d 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 53/83] 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 47a5585..f43a020 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 54/83] 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 c01516c..0ef7587 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 6bf24fc..4cd137a 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 22aa462..3c1b0b6 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 4e22947..db25110 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 55/83] 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 794ccce..2518b54 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 0000000..7cc1be8 --- /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 b9c689b..3159c2e 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 9917d31..b4bc6ca 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 9f61c0b..1c5f72c 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 f59f676..fa0a860 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 56/83] 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 2518b54..3e29476 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 c8d208a..8f86744 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 d294318..db55f5e 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 0000000..f89f326 --- /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 fa0a860..207a36d 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 877819a..485bca2 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 0000000..b7ac299 --- /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 57/83] 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 35e704e..5eb8dfd 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 86dfa73..28d0913 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 ea65403..7701ccd 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 ea5ea7f..b71f6cd 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 58/83] 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 463e4d3..9c15bc8 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 0000000..4e11651 --- /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 0000000..808799f --- /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 59/83] 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 9c15bc8..53d5a00 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 69619be..e9c5d4b 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 3ccaac2..3b3b5c7 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 7413a4a..c6bc09f 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 bbe4255..c2fa068 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 1283b5f..175ef3a 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 60/83] 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 53d5a00..fd6cb68 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 c6bc09f..f33e32d 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 0000000..787a4fe --- /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 61/83] 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 0000000..8df354d --- /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 0000000..cc36d85 --- /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 62/83] 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 cc36d85..e17ed59 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 63/83] 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 0000000..427dde6 --- /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 0000000..0a5aec0 --- /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 0000000..3551d52 --- /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 64/83] 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 3551d52..0843834 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 65/83] 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 0000000..68b3212 --- /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 0000000..3e55fb2 --- /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 66/83] 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 68b3212..5433e94 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 3e55fb2..7a5e7c4 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 67/83] 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 0000000..a9bb568 --- /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 0000000..1b7ecad --- /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 68/83] 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 0000000..36d0da1 --- /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 0000000..3fbd120 --- /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 69/83] 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 36d0da1..f81b389 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)
{o.pathLength.hopCount} {o.pathBytes ? ( - + ) : ( )} diff --git a/tests/features/packets/ObservationTable.test.tsx b/tests/features/packets/ObservationTable.test.tsx index 3fbd120..d82d95f 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 70/83] 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 0000000..ee196a6 --- /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 0000000..b1b9074 --- /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 71/83] 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 ee196a6..a412870 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 b1b9074..db7a6cf 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 72/83] 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 fd6cb68..28c68b8 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 a412870..49c0c05 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 f33e32d..0340b6b 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 175ef3a..d704489 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 0340b6b..58d8a60 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 0000000..4ac8c33 --- /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 db7a6cf..b9edab0 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 d704489..274367e 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 60339ec..449afa7 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 74/83] 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 4207830..08cd63a 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 58d8a60..0340b6b 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 274367e..d704489 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 75/83] 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 5b77996..8eed3e2 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 4e11651..fa3316d 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 28d0913..f50ac80 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 0000000..83dc167 --- /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 c626adc..17595f8 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 c2fa068..8dc1853 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 b9edab0..cbbf9c0 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 78/83] 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 91e477b..7871077 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 db55f5e..6f9a522 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 79/83] 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 0000000..33e5822 --- /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 30bb1b7..d609c88 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 427dde6..0000000 --- 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 a9bb568..5366f45 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 5433e94..ff67b15 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 8eed3e2..2cf218b 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 0a5aec0..2845d8f 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 0000000..037489c --- /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 0843834..0000000 --- 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 1b7ecad..0f85043 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 7a5e7c4..d359771 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 80/83] 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 d609c88..043d10d 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 5366f45..97ae3da 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 ff67b15..95a430f 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 2cf218b..4cc15ad 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 cbbf9c0..889a7df 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 0f85043..de254c0 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 d359771..27435ae 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 3ead972..96f8028 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 81/83] 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 11a0254..bcff0da 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 87902eb..00dade5 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 217dd1a..ca7c44c 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 2b695d7..a73c01b 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"); + }); +});