diff --git a/src/App.tsx b/src/App.tsx index 6b750dc4..82424da1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -313,7 +313,7 @@ function AppInner() { // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses Traces: { if (hash) viewPacket(hash); }} onViewNode={viewNode} />, Channels: , - Analytics: , + Analytics: , Map: , }; diff --git a/src/api/client.ts b/src/api/client.ts index 1b47bfff..fcea6a51 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -5,7 +5,7 @@ import type { ChannelPage, ChannelMessage } from "../features/channels/types"; import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types"; import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types"; import type { - StatsOverview, + StatsSeries, SignalStats, PathStats, ObserverComparison, @@ -71,11 +71,11 @@ async function request(path: string, params?: Record { - return request("/stats/observer-comparison", { ...params, iatas: iatasParam(iatas) }, signal); + return request("/stats/observer-comparison", { ...params, ...statsRegionParams(iatas) }, signal); } // The region filter travels as the comma-separated `iatas` param; undefined/empty means all regions. @@ -83,10 +83,22 @@ function iatasParam(iatas?: string[]): string | undefined { return iatas && iatas.length > 0 ? iatas.join(",") : undefined; } +// An empty list is a region with no IATAs. These endpoints would answer it with every IATA, so skip them. +const noIatas = (iatas?: string[]) => iatas?.length === 0; +const emptyPage = (): CursorPage => ({ items: [], nextCursor: null, hasMore: false }); + +// Stats endpoints also take a region slug, which the server answers with zeros when it has no IATAs. +export type StatsRegion = string[] | { region: string } | undefined; + +function statsRegionParams(r: StatsRegion): { iatas?: string; region?: string } { + return Array.isArray(r) || r === undefined ? { iatas: iatasParam(r) } : { region: r.region }; +} + export function getPackets( iatas: string[] | undefined, params?: { cursor?: number; limit?: number; payloadTypes?: number[]; routeTypes?: number[]; scopes?: string[] }, ): Promise> { + if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/packets", { iatas: iatasParam(iatas), cursor: params?.cursor, @@ -127,6 +139,7 @@ export function getRegion(regionId: number): Promise { // Preserve the server cursor rather than deriving it from the displayed channel order. export function getChannels(params?: { iatas?: string[]; limit?: number; cursor?: number | string; keyKnown?: boolean }): Promise { + if (noIatas(params?.iatas)) return Promise.resolve(emptyPage()); const iatas = params?.iatas ?? []; return request("/channels", { iata: iatas.length === 1 ? iatas[0] : undefined, @@ -145,6 +158,7 @@ export async function getChannelMessagesPage( channelId: number, params?: { iatas?: string[]; cursor?: number; limit?: number; scope?: string }, ): Promise> { + if (noIatas(params?.iatas)) return emptyPage(); const limit = params?.limit ?? DEFAULT_PAGE_SIZE; const page = await request<{ items: ChannelMessage[]; nextCursor?: number | null; hasMore?: boolean }>(`/channels/${channelId}/messages`, { iatas: iatasParam(params?.iatas), @@ -163,6 +177,7 @@ export function getBrokers(): Promise { // Transport scope names (e.g. "#bc", "#west") for the scope filter dropdowns. With IATAs the server // returns only that region's scopes (manual config + its MeshMapper catalogue); bare, every stored name. export function getScopes(iatas?: string[]): Promise { + if (noIatas(iatas)) return Promise.resolve([]); return request("/scopes", { iatas: iatasParam(iatas) }); } @@ -217,6 +232,7 @@ export function getTraces( iatas: string[] | undefined, params?: { scope?: string; type?: TraceType; since?: number; until?: number; cursor?: number; limit?: number }, ): Promise { + if (noIatas(iatas)) return Promise.resolve([]); return request("/traces", { iatas: iatasParam(iatas), scope: params?.scope, @@ -268,6 +284,7 @@ export function getNodesPage( neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, ): Promise> { + if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/nodes", { iatas: iatasParam(iatas), cursor: params?.cursor, @@ -286,6 +303,7 @@ export function getObserversPage( iatas: string[] | undefined, params?: { cursor?: number; limit?: number; type?: string; broker?: string; status?: string; name?: string }, ): Promise> { + if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/observers", { iatas: iatasParam(iatas), cursor: params?.cursor, @@ -317,60 +335,60 @@ export function getNodeNeighbors(nodeId: string): Promise { // stats endpoints -export function getStatsOverview(iatas?: string[]): Promise { - return request("/stats/overview", { iatas: iatasParam(iatas) }); +export function getStatsSeries(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise { + return request("/stats/series", { since, until, ...statsRegionParams(iatas) }, signal); } -export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise { - return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal); +export function getStatsObservations(iatas?: StatsRegion, since?: number, signal?: AbortSignal): Promise { + return request("/stats/observations", { ...statsRegionParams(iatas), since }, signal); } -export function getSignalStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise { - return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal); +export function getSignalStats(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise { + return request("/stats/signal", { since, until, ...statsRegionParams(iatas) }, signal); } -export function getPathStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise { - return request("/stats/paths", { since, until, iatas: iatasParam(iatas) }, signal); +export function getPathStats(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise { + return request("/stats/paths", { since, until, ...statsRegionParams(iatas) }, signal); } -export function getPayloadBreakdown(iatas?: string[], since?: number): Promise { - return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since }); +export function getPayloadBreakdown(iatas?: StatsRegion, since?: number): Promise { + return request("/stats/payload-breakdown", { ...statsRegionParams(iatas), since }); } -export function getTopNodes(iatas?: string[], limit = 10): Promise { - return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit }); +export function getTopNodes(iatas?: StatsRegion, since?: number, limit = 10): Promise { + return request("/stats/top-nodes", { ...statsRegionParams(iatas), since, limit }); } -export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise { - return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit }); +export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10): Promise { + return request("/stats/top-observers", { ...statsRegionParams(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 getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise { + return request("/stats/top-advertisers", { ...statsRegionParams(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 getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise { + return request("/stats/top-talkers", { ...statsRegionParams(iatas), since, limit }); } -export function getRadioPresets(iatas?: string[]): Promise { - return request("/stats/radio-presets", { iatas: iatasParam(iatas) }); +export function getRadioPresets(iatas?: StatsRegion): Promise { + return request("/stats/radio-presets", { ...statsRegionParams(iatas) }); } -export function getStatsNodeTypes(iatas?: string[]): Promise { - return request("/stats/node-types", { iatas: iatasParam(iatas) }); +export function getStatsNodeTypes(iatas?: StatsRegion): Promise { + return request("/stats/node-types", { ...statsRegionParams(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 }); +export function getClockDrift(iatas?: StatsRegion, limit = 100): Promise { + return request("/stats/clock-drift", { ...statsRegionParams(iatas), limit }); } // renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes // aggregate (packet/observer/node counts), filtered by the selected IATAs. -export function getStatsScopes(iatas?: string[], signal?: AbortSignal): Promise { - return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal); +export function getStatsScopes(iatas?: StatsRegion, since?: number, signal?: AbortSignal): Promise { + return request("/stats/scopes", { ...statsRegionParams(iatas), since }, signal); } export function getObserverTelemetry( diff --git a/src/api/ws-manager.ts b/src/api/ws-manager.ts index f60eaebc..f4f86978 100644 --- a/src/api/ws-manager.ts +++ b/src/api/ws-manager.ts @@ -257,7 +257,8 @@ export class WsManager { } private sendSubscribe(): void { - if (!this.filter) return; + // the server reads an empty IATA list as "all", so a region with no IATAs subscribes to nothing + if (!this.filter || this.filter.iatas?.length === 0) return; const id = `sub-${this.nextId()}`; this.lastSubscribeId = id; this.send({ diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx index 674e08b0..054d7f33 100644 --- a/src/features/map/MapView.tsx +++ b/src/features/map/MapView.tsx @@ -173,7 +173,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp const fitPoints = useMemo<[number, number][] | null>(() => { const withCoords = (iatas ?? []).filter((i) => i.lat != null && i.lon != null); if (withCoords.length === 0) return null; - const scope = selectedIatas && selectedIatas.length > 0 ? new Set(selectedIatas) : null; + const scope = selectedIatas ? new Set(selectedIatas) : null; const chosen = scope ? withCoords.filter((i) => scope.has(i.iata)) : withCoords; return chosen.length > 0 ? chosen.map((i) => [i.lon!, i.lat!]) : null; }, [iatas, selectedIatas]); @@ -182,7 +182,7 @@ export function MapView({ wsManager, selectedNodeId, onSelectNode }: MapViewProp // 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; + return selectedIatas ? all.filter((c) => selectedIatas.includes(c)) : all; }, [iatas, selectedIatas]); const borderData = useMapBordersData(borderIatas, borders); diff --git a/src/features/observers/ObserverSidebar.tsx b/src/features/observers/ObserverSidebar.tsx index c21e0a66..626fe054 100644 --- a/src/features/observers/ObserverSidebar.tsx +++ b/src/features/observers/ObserverSidebar.tsx @@ -2,9 +2,9 @@ import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getTopObservers } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; import { formatCount } from "../../lib/formatters"; import { Segmented } from "../stats/Segmented"; +import { useStatsRegion } from "../stats/useStats"; import { RANGE_MS, type StatsRange } from "../stats/types"; import { observerName as nameOf } from "./observer-filter"; import { deriveObserverStatus } from "./observer-status"; @@ -18,12 +18,12 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, onRet range: StatsRange; selectedId: string | null; onSelect: (id: string) => void; }) { const { t } = useTranslation(); - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); const [sort, setSort] = useState("activity"); const activity = useQuery({ queryKey: ["observer-sidebar-activity", regionKey, range], - queryFn: () => getTopObservers(iatas, Date.now() - RANGE_MS[range], TOP), + queryFn: () => getTopObservers(where, Date.now() - RANGE_MS[range], TOP), staleTime: 30_000, }); diff --git a/src/features/routes/RouteTable.tsx b/src/features/routes/RouteTable.tsx index 41702422..19828f54 100644 --- a/src/features/routes/RouteTable.tsx +++ b/src/features/routes/RouteTable.tsx @@ -240,7 +240,7 @@ export function RouteTable(actions: RouteActions) { // region = all). Filtering by IATA stays client-side, consistent with the other tabs. const rows = useMemo(() => { if (search) return isCross ? [] : searchRoutes; - if (!iatas || iatas.length === 0) return listRoutes; + if (!iatas) return listRoutes; const set = new Set(iatas); return listRoutes.filter((r) => set.has(r.iata)); }, [search, isCross, searchRoutes, listRoutes, iatas]); diff --git a/src/features/stats/CompareObserversTab.tsx b/src/features/stats/CompareObserversTab.tsx index 55574ef0..f750e323 100644 --- a/src/features/stats/CompareObserversTab.tsx +++ b/src/features/stats/CompareObserversTab.tsx @@ -6,6 +6,7 @@ import { InfoTip } from "../../components/InfoTip"; import { SectionInfo } from "./SectionInfo"; import { getObserver, getObserverComparison } from "../../api/client"; import { useRegion } from "../../hooks/useRegion"; +import { useStatsRegion } from "./useStats"; import { OBSERVER_UUID } from "../observers/observer-id"; import { ObserverPicker } from "../observers/ObserverPicker"; import { Card } from "./cards"; @@ -82,7 +83,8 @@ function ComparisonForm({ initial, onCompare }: { initial: Selection | null; onC export function CompareObserversTab() { const { t } = useTranslation(); - const { iatas, regionKey } = useRegion(); + const { iatas, emptyRegion } = useRegion(); + const { where, regionKey } = useStatsRegion(); const [params, setParams] = useSearchParams(); const keys = ["compareA", "compareB", "compareSince", "compareUntil"]; const supplied = keys.some((key) => params.has(key)); @@ -94,7 +96,7 @@ export function CompareObserversTab() { const selection = valid ? parsed : null; const result = useQuery({ queryKey: ["observer-comparison", regionKey, selection], - queryFn: ({ signal }) => getObserverComparison(iatas, selection!, signal), + queryFn: ({ signal }) => getObserverComparison(where, selection!, signal), enabled: selection !== null, retry: false, staleTime: 30_000, refetchOnWindowFocus: false, }); @@ -133,7 +135,7 @@ export function CompareObserversTab() { {selection &&

{t("observerCompare.pair", { a: observerA.data?.displayName ?? selection.observerA, b: observerB.data?.displayName ?? selection.observerB })}

-

{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas?.join(", ") || t("observerCompare.allRegions")}

+

{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas ? iatas.join(", ") || emptyRegion : t("observerCompare.allRegions")}

{result.isFetching &&

{t("observerCompare.comparing")}

} {result.isError &&

{result.error.message}

} {data && !result.isError && <> diff --git a/src/features/stats/MeshTab.tsx b/src/features/stats/MeshTab.tsx index aa79119b..dca6fd29 100644 --- a/src/features/stats/MeshTab.tsx +++ b/src/features/stats/MeshTab.tsx @@ -1,59 +1,42 @@ import { useTranslation } from "react-i18next"; import { useMemo } from "react"; -import { formatCount } from "../../lib/formatters"; +import { formatCount, formatUtc } from "../../lib/formatters"; import { useChartColors, nodeTypeColor } from "./chartTheme"; -import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; +import { useStatsSeries, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats"; import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } from "./chartOptions"; import { ChartCard, StatCard } from "./cards"; -import { useLiveOverview } from "./useLiveStats"; import { aggregatePresets, formatPreset, payloadBarItems } from "./transforms"; -import type { WsManager } from "../../api/ws-manager"; -import type { ObservationPoint, StatsRange } from "./types"; +import type { StatsRange, StatsSeries, SeriesValues } from "./types"; // Keep unavailable values in the cache, but do not display them under the current filters. function readyData(query: { data: T | undefined; isSuccess: boolean; isPlaceholderData: boolean }) { return query.isSuccess && !query.isPlaceholderData ? query.data : undefined; } -// The observations endpoint returns one row per hour+iata; collapse to one row per hour (a no-op for a -// single selected region). uniquePackets / activeObservers summed across iatas are approximate. -function aggregateByHour(points: ObservationPoint[]) { - const byHour = new Map(); - for (const p of points) { - const cur = byHour.get(p.hour) ?? { hour: p.hour, observationCount: 0, uniquePackets: 0, activeObservers: 0 }; - cur.observationCount += p.observationCount; - cur.uniquePackets += p.uniquePackets; - cur.activeObservers += p.activeObservers; - byHour.set(p.hour, cur); - } - return [...byHour.values()].sort((a, b) => a.hour - b.hour); +// Missing and partial hours carry no values; keep their slot as null so the sparkline shows a gap. +function spark(series: StatsSeries | undefined, pick: (v: SeriesValues) => number) { + return series?.hours.map((h) => (h.values ? pick(h.values) : null)); } interface MeshTabProps { range: StatsRange; onSelectObserver: (observerId: string) => void; - wsManager: WsManager; } -export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { +export function MeshTab({ range, onSelectObserver }: MeshTabProps) { const { t } = useTranslation(); const colors = useChartColors(); - 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 series = useStatsSeries("24h"); + const observations = useStatsSeries(range); const payload = usePayloadBreakdown(range); - const topNodes = useTopNodes(10); + const topNodes = useTopNodes(range, 10); const topObservers = useTopObservers(range, 8); const radioPresets = useRadioPresets(); - const scopes = useScopes(); + const scopes = useScopes(range); const nodeTypes = useNodeTypes(); - const ov = readyData(overview); + const ov = readyData(series); const observationsData = readyData(observations); - const overviewObsData = readyData(overviewObs); const payloadData = readyData(payload); const topNodesData = readyData(topNodes); const topObserversData = readyData(topObservers); @@ -61,7 +44,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const scopesData = readyData(scopes); const nodeTypesData = readyData(nodeTypes); - const obs = useMemo(() => aggregateByHour(observationsData ?? []), [observationsData]); + const obs = useMemo( + () => (observationsData?.hours ?? []).map((h) => ({ hour: h.hour, observations: h.values?.observations ?? null, uniquePackets: h.values?.uniquePackets ?? null })), + [observationsData], + ); const obsOption = useMemo( () => observationsAreaOption(obs, colors, { observations: t("mesh.observations"), uniquePackets: t("charts.uniquePackets") }), [obs, colors, t], @@ -70,7 +56,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { const nodeRows = useMemo( () => (topNodesData ?? []).map((n) => ({ - name: n.nodeName ?? n.nodeId.slice(0, 8), + name: n.nodeName ?? n.publicKey.slice(0, 8), value: n.observationCount, color: nodeTypeColor(n.nodeTypeName, colors), })), @@ -122,20 +108,21 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { [scopesData], ); - const kpiObs = useMemo(() => aggregateByHour(overviewObsData ?? []), [overviewObsData]); - const obsSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.observationCount), [kpiObs]); - const observerSpark = useMemo(() => kpiObs.slice(-24).map((p) => p.activeObservers), [kpiObs]); + const obsSpark = useMemo(() => spark(ov, (v) => v.observations), [ov]); + const observerSpark = useMemo(() => spark(ov, (v) => v.activeObservers), [ov]); - // top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below - const ovWindow = t("mesh.lastHours", { hours: ov?.windowHours ?? 24 }); + // top-row KPIs are the last 24 rollable hours, so they lag the clock by 35–95 min; range only drives the charts below + const ovWindow = ov + ? t("mesh.lastHoursTo", { hours: Math.round((ov.until - ov.since) / 3_600_000), time: formatUtc(ov.until, { timeOnly: true }) }) + : t("mesh.lastHours", { hours: 24 }); return (
- - - - + + + +
@@ -161,8 +148,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) { isError={payload.isError} isEmpty={payloadItems.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/ScopesTab.tsx b/src/features/stats/ScopesTab.tsx index 1a0b83f1..e6823119 100644 --- a/src/features/stats/ScopesTab.tsx +++ b/src/features/stats/ScopesTab.tsx @@ -7,10 +7,11 @@ import { useChartColors } from "./chartTheme"; import { Card, ChartCard, StatCard } from "./cards"; import { scopeChartOption, scopeSummary } from "./scopes"; import { formatCount } from "../../lib/formatters"; +import type { StatsRange } from "./types"; -export function ScopesTab() { +export function ScopesTab({ range }: { range: StatsRange }) { const { t } = useTranslation(); - const query = useScopes(); + const query = useScopes(range); const colors = useChartColors(); const [search, setSearch] = useState(""); const loading = query.isPending || query.isLoading || query.isPlaceholderData; diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index 9eff5c56..8f1ef4f5 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; -import type { WsManager } from "../../api/ws-manager"; import { StatsSubHeader } from "./StatsSubHeader"; import { SectionInfoSlot } from "./section-info-slot"; import { MeshTab } from "./MeshTab"; @@ -22,13 +21,12 @@ const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d"); interface StatsOverviewProps { - wsManager: WsManager; onViewNode?: (nodeId: string) => void; } // Stats page shell: an analytics sub-header and range over the active sub-tab. Sub-tab and range live // in the URL (?statsTab/?range) so the view is shareable. -export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) { +export function StatsOverview({ onViewNode }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const navigate = useNavigate(); const tab = asTab(params.get("statsTab")); @@ -67,11 +65,11 @@ export function StatsOverview({ wsManager, onViewNode }: StatsOverviewProps) {
- {tab === "mesh" && } + {tab === "mesh" && } {tab === "traffic" && } {tab === "signal" && } {tab === "paths" && } - {tab === "scopes" && } + {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } {tab === "graph" && } diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index ffef33a7..5150838d 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -132,7 +132,7 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, infoSlo
{/* Comparison has explicit dates; graph and clock drift have no rolling window. */} - {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && ( + {tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && ( (
- {a.nodeName ?? a.nodeId.slice(0, 8)} + {a.nodeName ?? a.publicKey.slice(0, 8)} {a.nodeTypeName} {a.iata}
), - sortValue: (a) => a.nodeName ?? a.nodeId, + sortValue: (a) => a.nodeName ?? a.publicKey, }, { header: t("talkers.flood"), className: "tabular-nums", cell: (a) => split(a.floodAdvertCount), sortValue: (a) => a.floodAdvertCount }, { header: t("talkers.direct"), className: "tabular-nums", cell: (a) => split(a.directAdvertCount), sortValue: (a) => a.directAdvertCount }, @@ -75,9 +75,9 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) { a.nodeId} + rowKey={(a) => a.publicKey} selectedKey={null} - onSelect={(id) => { if (id) onViewNode?.(id); }} + onSelect={(key) => { const id = advertisers.find((a) => a.publicKey === key)?.nodeId; if (id) onViewNode?.(id); }} isLoading={advertisersLoading} emptyLabel={topAdvertisers.isError ? t("common.loadFailed") : t("talkers.noAdvertisers")} /> diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 5287bd19..ae05df4d 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -26,19 +26,25 @@ export function Card({ ); } -function Sparkline({ values, color }: { values: number[]; color: string }) { - if (values.length < 2) return
; +function Sparkline({ values, color }: { values: (number | null)[]; color: string }) { + const known = values.filter((v): v is number => v !== null); + if (values.length < 2 || known.length < 2) return
; const w = 120; const h = 20; - const max = Math.max(...values); - const min = Math.min(...values); + const max = Math.max(...known); + const min = Math.min(...known); const range = max - min || 1; - const pts = values - .map((v, i) => `${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`) - .join(" "); + // A null hour breaks the line, so an outage shows as a gap rather than a straight join. + const runs: string[][] = [[]]; + values.forEach((v, i) => { + if (v === null) runs.push([]); + else runs[runs.length - 1]!.push(`${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`); + }); return ( - + {runs.filter((run) => run.length > 1).map((run) => ( + + ))} ); } @@ -54,7 +60,7 @@ export function StatCard({ label: string; value: ReactNode; accent: string; // CSS color for the sparkline, e.g. "var(--color-primary)" - spark?: number[]; + spark?: (number | null)[]; sublabel?: ReactNode; }) { return ( diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index 0d41d00e..d349f5ea 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -29,13 +29,12 @@ function valueAxis(c: ChartColors, extra: Record = {}) { // ---- Mesh ---- +// null values are hours the server hasn't rolled (or never can); echarts breaks the line there. export function observationsAreaOption( - points: { hour: number; observationCount: number; uniquePackets: number }[], + points: { hour: number; observations: number | null; uniquePackets: number | null }[], c: ChartColors, labels = { observations: "Observations", uniquePackets: "Unique packets" }, ): EChartsOption { - const obs = points.map((p) => [p.hour, p.observationCount]); - const uniq = points.map((p) => [p.hour, p.uniquePackets]); return { animation: false, backgroundColor: "transparent", @@ -58,7 +57,7 @@ export function observationsAreaOption( type: "line", smooth: true, symbol: "none", - data: obs, + data: points.map((p) => [p.hour, p.observations]), lineStyle: { color: c.primary, width: 2 }, itemStyle: { color: c.primary }, areaStyle: { @@ -77,7 +76,7 @@ export function observationsAreaOption( type: "line", smooth: true, symbol: "none", - data: uniq, + data: points.map((p) => [p.hour, p.uniquePackets]), lineStyle: { color: c.secondary, width: 1.3, type: "dashed" }, itemStyle: { color: c.secondary }, }, diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index fe00fcfb..e20af524 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -16,20 +16,40 @@ export interface ObserverComparison { both: number; } -export interface StatsOverview { - totalPackets: number; - totalObservations: number; +// beacon-server /stats/series: hourly rollups. Only complete hours carry values; the summary covers +// those, and observers/IATAs/scopes are distinct across the window so they don't sum from the hours. +export interface SeriesValues { + observations: number; + uniquePackets: number; activeObservers: number; activeIatas: number; - windowHours: number; + scopedPackets: number; + activeScopes: number; + maxPathEntries: number; + snrSum: number; + snrSamples: number; + rssiSum: number; + rssiSamples: number; +} +export interface SeriesHour { + hour: number; // epoch ms, UTC hour start + status: "complete" | "partial" | "missing"; + values: SeriesValues | null; +} +export interface StatsSeries { + since: number; + until: number; + revision: number; + earliestComplete: number | null; + completeHours: number; + hours: SeriesHour[]; + summary: SeriesValues; } export interface ObservationPoint { hour: number; // epoch ms, start of the hourly bucket iata: string; observationCount: number; - uniquePackets: number; - activeObservers: number; } export interface PayloadBreakdownItem { @@ -39,7 +59,8 @@ export interface PayloadBreakdownItem { } export interface TopNode { - nodeId: string; + nodeId: string | null; // null once the node row is deleted; key rows by publicKey + publicKey: string; nodeName: string | null; nodeType: number; nodeTypeName: string; @@ -57,7 +78,8 @@ export interface TopObserver { } export interface TopAdvertiser { - nodeId: string; + nodeId: string | null; // null once the node row is deleted; key rows by publicKey + publicKey: string; nodeName: string | null; nodeType: number; nodeTypeName: string; diff --git a/src/features/stats/useLiveStats.ts b/src/features/stats/useLiveStats.ts index e0c9f19f..f14e3fe1 100644 --- a/src/features/stats/useLiveStats.ts +++ b/src/features/stats/useLiveStats.ts @@ -1,58 +1,9 @@ -import { useCallback, useEffect, useRef } from "react"; +import { useCallback } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { useRegion } from "../../hooks/useRegion"; -import { useWsPacketHandler, useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; +import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; import type { WsManager } from "../../api/ws-manager"; -import type { WsPacketObservation, WsObserverStatus } from "../../types/ws"; -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(); - const pending = useRef({ packets: 0, obs: 0 }); - const raf = useRef(null); - - const flush = useCallback(() => { - raf.current = null; - const { packets, obs } = pending.current; - if (!packets && !obs) return; - pending.current = { packets: 0, obs: 0 }; - // setQueryData would clear an error state and show stale numbers as healthy — drop the deltas instead. - if (qc.getQueryState(["stats-overview", regionKey])?.status !== "success") return; - qc.setQueryData(["stats-overview", regionKey], (old) => - old - ? { ...old, totalPackets: old.totalPackets + packets, totalObservations: old.totalObservations + obs } - : old, - ); - }, [qc, regionKey]); - - const onPacket = useCallback( - (data: WsPacketObservation["data"]) => { - pending.current.obs += 1; - if (data.packet?.isFirstObservation) pending.current.packets += 1; - if (raf.current == null) raf.current = requestAnimationFrame(flush); - }, - [flush], - ); - - useWsPacketHandler(wsManager, onPacket); - - // drop counts accumulated for the previous region so they don't bleed into the new one's KPIs - useEffect(() => { - pending.current = { packets: 0, obs: 0 }; - }, [regionKey]); - - useEffect( - () => () => { - if (raf.current != null) cancelAnimationFrame(raf.current); - }, - [], - ); -} +import type { WsObserverStatus } from "../../types/ws"; +import type { StatsRange } from "./types"; // When the selected observer reports a status update, refresh its header + telemetry so battery, // uptime, and the newest points reflect the change. Status messages are infrequent, so a refetch is fine. diff --git a/src/features/stats/useStats.ts b/src/features/stats/useStats.ts index d2edd306..8023045d 100644 --- a/src/features/stats/useStats.ts +++ b/src/features/stats/useStats.ts @@ -1,7 +1,7 @@ import { useQuery, keepPreviousData } from "@tanstack/react-query"; import { useRegion } from "../../hooks/useRegion"; import { - getStatsOverview, + getStatsSeries, getStatsObservations, getPayloadBreakdown, getTopNodes, @@ -12,6 +12,7 @@ import { getStatsScopes, getStatsNodeTypes, getClockDrift, + type StatsRegion, } from "../../api/client"; import { RANGE_MS, type StatsRange } from "./types"; @@ -22,28 +23,46 @@ const common = { refetchOnWindowFocus: false, } as const; +// A region with no member IATAs is sent by slug so the server returns zeros rather than every IATA. +export function useStatsRegion() { + const { iatas, regionKey, isResolved, emptyRegion } = useRegion(); + const where: StatsRegion = emptyRegion ? { region: emptyRegion } : iatas; + return { where, regionKey, isResolved }; +} + // `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]; -export function useStatsOverview() { - const { iatas, regionKey } = useRegion(); +const HOUR_MS = 3_600_000; +// Same end as the server's overview window: the hour starting at H can be rolled from H + 95 min. +export function rolledWindow(range: StatsRange, now = Date.now()) { + const until = Math.floor((now - 95 * 60_000) / HOUR_MS) * HOUR_MS + HOUR_MS; + return { since: until - RANGE_MS[range], until }; +} + +export function useStatsSeries(range: StatsRange) { + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ - queryKey: ["stats-overview", regionKey], - queryFn: () => getStatsOverview(iatas), + queryKey: ["stats-series", isResolved === false ? `${regionKey}:pending` : regionKey, range], + enabled: isResolved !== false, + queryFn: ({ signal }) => { + if (isResolved === false) throw new Error("Selected region is not available yet"); + const { since, until } = rolledWindow(range); + return getStatsSeries(since, until, where, signal); + }, ...common, - // self-correct the WS-accumulated live counters against the server refetchInterval: 60_000, }); } export function useStatsObservations(range: StatsRange) { - const { iatas, regionKey, isResolved } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: ["stats-observations", isResolved === false ? `${regionKey}:pending` : regionKey, range], enabled: isResolved !== false, queryFn: ({ signal }) => { if (isResolved === false) throw new Error("Selected region is not available yet"); - return getStatsObservations(iatas, sinceFor(range), signal); + return getStatsObservations(where, sinceFor(range), signal); }, ...common, // feeds the observations chart + sparklines and gets no WS bumps, so refetch to stay fresh @@ -52,88 +71,88 @@ export function useStatsObservations(range: StatsRange) { } export function usePayloadBreakdown(range: StatsRange) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-payload", regionKey, range], - queryFn: () => getPayloadBreakdown(iatas, sinceFor(range)), + queryFn: () => getPayloadBreakdown(where, sinceFor(range)), ...common, }); } -export function useTopNodes(limit = 10) { - const { iatas, regionKey } = useRegion(); +export function useTopNodes(range: StatsRange, limit = 10) { + const { where, regionKey } = useStatsRegion(); return useQuery({ - queryKey: ["stats-top-nodes", regionKey, limit], - queryFn: () => getTopNodes(iatas, limit), + queryKey: ["stats-top-nodes", regionKey, range, limit], + queryFn: () => getTopNodes(where, sinceFor(range), limit), ...common, }); } export function useTopObservers(range: StatsRange, limit = 10) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-top-observers", regionKey, range, limit], - queryFn: () => getTopObservers(iatas, sinceFor(range), limit), + queryFn: () => getTopObservers(where, sinceFor(range), limit), ...common, }); } export function useTopAdvertisers(range: StatsRange, limit = 10) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-top-advertisers", regionKey, range, limit], - queryFn: () => getTopAdvertisers(iatas, sinceFor(range), limit), + queryFn: () => getTopAdvertisers(where, sinceFor(range), limit), ...common, }); } export function useTopTalkers(range: StatsRange, limit = 10) { - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-top-talkers", regionKey, range, limit], - queryFn: () => getTopTalkers(iatas, sinceFor(range), limit), + queryFn: () => getTopTalkers(where, sinceFor(range), limit), ...common, }); } export function useRadioPresets() { - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-radio-presets", regionKey], - queryFn: () => getRadioPresets(iatas), + queryFn: () => getRadioPresets(where), ...common, }); } // node-types is a population census (no time window), so the key is region-only export function useNodeTypes() { - const { iatas, regionKey } = useRegion(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-node-types", regionKey], - queryFn: () => getStatsNodeTypes(iatas), + queryFn: () => getStatsNodeTypes(where), ...common, }); } // 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(); + const { where, regionKey } = useStatsRegion(); return useQuery({ queryKey: ["stats-clock-drift", regionKey, limit], - queryFn: () => getClockDrift(iatas, limit), + queryFn: () => getClockDrift(where, limit), ...common, }); } -// Scope counts have no time window, but the selected region changes their membership. -export function useScopes() { - const { iatas, regionKey, isResolved } = useRegion(); +// Packet counts are windowed; observer and node counts are current membership. +export function useScopes(range: StatsRange) { + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ - queryKey: ["stats-scopes", isResolved === false ? `${regionKey}:pending` : regionKey], + queryKey: ["stats-scopes", isResolved === false ? `${regionKey}:pending` : regionKey, range], enabled: isResolved !== false, queryFn: ({ signal }) => { if (isResolved === false) throw new Error("Selected region is not available yet"); - return getStatsScopes(iatas, signal); + return getStatsScopes(where, sinceFor(range), signal); }, ...common, refetchInterval: 60_000, diff --git a/src/features/stats/useWindowedStats.ts b/src/features/stats/useWindowedStats.ts index f6209283..2289c90b 100644 --- a/src/features/stats/useWindowedStats.ts +++ b/src/features/stats/useWindowedStats.ts @@ -1,5 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { useRegion } from "../../hooks/useRegion"; +import type { StatsRegion } from "../../api/client"; +import { useStatsRegion } from "./useStats"; import { RANGE_MS, type StatsRange } from "./types"; // Shared by usePathStats/useSignalStats (and any future /stats/* window query): resolves the @@ -7,9 +8,9 @@ import { RANGE_MS, type StatsRange } from "./types"; export function useWindowedStats( key: string, range: StatsRange, - fetch: (since: number, until: number, iatas: string[] | undefined, signal?: AbortSignal) => Promise, + fetch: (since: number, until: number, where: StatsRegion, signal?: AbortSignal) => Promise, ) { - const { iatas, regionKey, isResolved } = useRegion(); + const { where, regionKey, isResolved } = useStatsRegion(); return useQuery({ queryKey: [key, isResolved === false ? `${regionKey}:pending` : regionKey, range], enabled: isResolved !== false, @@ -17,7 +18,7 @@ export function useWindowedStats( if (isResolved === false) throw new Error("Selected region is not available yet"); // Shared minute boundaries let viewers reuse server aggregates without changing the key every render. const until = Math.floor(Date.now() / 60_000) * 60_000; - return fetch(until - RANGE_MS[range], until, iatas, signal); + return fetch(until - RANGE_MS[range], until, where, signal); }, staleTime: 30_000, refetchInterval: 60_000, diff --git a/src/features/traces/TraceList.tsx b/src/features/traces/TraceList.tsx index 14674eb4..acb67989 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -7,6 +7,7 @@ import { SkeletonRows } from "../../components/SkeletonRows"; import { EmptyState } from "../../components/EmptyState"; import { Timestamp } from "../../components/Timestamp"; import { Badge } from "../../components/Badge"; +import { InfoTip } from "../../components/InfoTip"; import { Segmented } from "../stats/Segmented"; import { snrLevel, SIGNAL_LEVEL_CLASSES, formatSnr } from "../../lib/formatters"; import { TraceDetailPanel } from "./TraceDetailPanel"; @@ -121,12 +122,15 @@ export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { {tags ? t("traces.tagCount", { count: tags.length }) : ""} - setTypeFilter(v as "" | TraceType)} - ariaLabel={t("traces.typeLabel")} - /> +
+ + setTypeFilter(v as "" | TraceType)} + ariaLabel={t("traces.typeLabel")} + /> +
{isLoading ? ( diff --git a/src/hooks/region-selection.ts b/src/hooks/region-selection.ts index 04695988..befefd19 100644 --- a/src/hooks/region-selection.ts +++ b/src/hooks/region-selection.ts @@ -32,6 +32,16 @@ export function resolveIatas( return [...set].sort(); } +// A selection of loaded regions with no member IATAs (and no picked IATAs) resolves like "all"; return +// one of its slugs so callers can ask the server for that region instead of every IATA. +export function emptyRegionSlug( + selection: RegionSelection, + regionIatas: ReadonlyMap, +): string | undefined { + if (isAllRegions(selection) || !selection.regions.every((slug) => regionIatas.has(slug))) return undefined; + return resolveIatas(selection, regionIatas) ? undefined : selection.regions[0]; +} + // Stable query-key fragment for a resolved IATA list. "*" stands in for "all regions". export function regionKey(iatas: string[] | undefined): string { return iatas && iatas.length > 0 ? iatas.join(",") : "*"; diff --git a/src/hooks/useRegion.tsx b/src/hooks/useRegion.tsx index fac78248..f131f8b5 100644 --- a/src/hooks/useRegion.tsx +++ b/src/hooks/useRegion.tsx @@ -4,6 +4,7 @@ import { useQuery } from "@tanstack/react-query"; import { getRegions, getRegion } from "../api/client"; import { resolveIatas, + emptyRegionSlug, regionKey as toRegionKey, serializeSelection, type RegionSelection, @@ -78,9 +79,10 @@ export function useRegions(): RegionsData { } export interface RegionFilter { - iatas: string[] | undefined; // resolved member IATAs to query; undefined = all regions + iatas: string[] | undefined; // resolved member IATAs to query; undefined = all regions, [] = a region with none regionKey: string; // stable query-key fragment ("*" = all) isResolved?: boolean; // false while a selected region's member IATAs are unavailable + emptyRegion?: string; // slug of a selected region that has no member IATAs } // The resolved geographic filter consumers pass to queries: the flattened IATA list plus a stable key. @@ -90,6 +92,12 @@ export function useRegion(): RegionFilter { return useMemo(() => { const iatas = resolveIatas(selection, regionIatas); - return { iatas, regionKey: toRegionKey(iatas), isResolved: selection.regions.every((slug) => regionIatas.has(slug)) }; + const emptyRegion = emptyRegionSlug(selection, regionIatas); + return { + iatas: emptyRegion ? [] : iatas, + regionKey: emptyRegion ? `region:${emptyRegion}` : toRegionKey(iatas), + isResolved: selection.regions.every((slug) => regionIatas.has(slug)), + emptyRegion, + }; }, [selection, regionIatas]); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index bbb0bcd7..cf5e1f52 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -75,11 +75,12 @@ "activeObservers": "Active observers", "activeIatas": "Active areas", "lastHours": "Last {{hours}}h", + "lastHoursTo": "Last {{hours}}h · to {{time}} UTC", "observationsTitle": "Observations · {{range}}", "topObservers": "Top observers · {{range}}", "payloadTypes": "Payload types · {{range}}", "obs": "{{value}} obs", - "topNodes": "Top nodes · all time", + "topNodes": "Top nodes · {{range}}", "nodeTypes": "Node types · all time", "radioPresets": "Radio presets · all time", "scope": "Scope", @@ -229,7 +230,7 @@ "scopedPackets": "Scoped packets", "memberships": "Observers", "defaultNodes": "Default-scope nodes", - "measurement": "An observer can belong to more than one scope. These counts aren't limited to a time range.", + "measurement": "Packet counts cover the selected range. Observer and node counts are current, and an observer can belong to more than one scope.", "packetsTitle": "Packets by scope", "membershipsTitle": "Observers by scope", "countsTitle": "Scope counts", @@ -748,6 +749,7 @@ "iata_one": "{{count}} iata", "iata_other": "{{count}} iata", "typeLabel": "Trace type", + "typeHint": "Filters whole tags: TRACE if any packet in the tag is multi-hop, otherwise PING. Scope and time filters match the tag's first hearing; counts and times always cover the whole tag.", "all": "All", "trace": "Trace", "ping": "Ping", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2f0e26e7..6ad52b19 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -77,11 +77,12 @@ "activeObservers": "Observateurs actifs", "activeIatas": "Zones actives", "lastHours": "Dernières {{hours}} h", + "lastHoursTo": "Dernières {{hours}} h · jusqu’à {{time}} UTC", "observationsTitle": "Observations · {{range}}", "topObservers": "Meilleurs observateurs · {{range}}", "payloadTypes": "Types de paquets · {{range}}", "obs": "{{value}} obs", - "topNodes": "Meilleurs nœuds · historique complet", + "topNodes": "Meilleurs nœuds · {{range}}", "nodeTypes": "Types de nœuds · historique complet", "radioPresets": "Préréglages radio · historique complet", "scope": "Scope", @@ -233,7 +234,7 @@ "scopedPackets": "Paquets avec scope", "memberships": "Observateurs", "defaultNodes": "Nœuds du scope par défaut", - "measurement": "Un observateur peut appartenir à plusieurs scopes. Ces effectifs ne sont pas limités à une période.", + "measurement": "Les paquets couvrent la période sélectionnée. Les observateurs et les nœuds sont comptés à l’heure actuelle, et un observateur peut appartenir à plusieurs scopes.", "packetsTitle": "Paquets par scope", "membershipsTitle": "Observateurs par scope", "countsTitle": "Effectifs des scopes", @@ -770,6 +771,7 @@ "iata_other": "{{count}} iata", "iata_many": "{{count}} iata", "typeLabel": "Type de trace", + "typeHint": "Filtre des étiquettes entières : TRACE si un paquet de l'étiquette est multi-saut, sinon PING. Les filtres de portée et de temps portent sur la première réception de l'étiquette ; les comptes et les heures couvrent toujours toute l'étiquette.", "all": "Tous", "trace": "Trace", "ping": "Ping", diff --git a/tests/api/analytics-client.test.ts b/tests/api/analytics-client.test.ts index 2684a2dc..12107fa4 100644 --- a/tests/api/analytics-client.test.ts +++ b/tests/api/analytics-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getStatsObservations, getStatsScopes, getSignalStats, getPathStats } from "../../src/api/client"; +import { getStatsObservations, getStatsScopes, getStatsSeries, getTopNodes, getSignalStats, getPathStats } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); @@ -18,10 +18,12 @@ it("forwards region and cancellation to both aggregate endpoints, omitting an em const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); vi.stubGlobal("fetch", fetcher); const controller = new AbortController(); - await getStatsScopes(["YVR", "YOW"], controller.signal); - expect(new URL(fetcher.mock.calls[0]![0] as string).searchParams.get("iatas")).toBe("YVR,YOW"); + await getStatsScopes(["YVR", "YOW"], 5678, controller.signal); + const scopesUrl = new URL(fetcher.mock.calls[0]![0] as string); + expect(scopesUrl.searchParams.get("iatas")).toBe("YVR,YOW"); + expect(scopesUrl.searchParams.get("since")).toBe("5678"); expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); - await getStatsScopes([]); + await getStatsScopes([], 5678); expect(new URL(fetcher.mock.calls[1]![0] as string).searchParams.has("iatas")).toBe(false); await getStatsObservations(["YOW"], 1234, controller.signal); const url = new URL(fetcher.mock.calls[2]![0] as string); @@ -40,3 +42,23 @@ it("sends explicit signal window bounds, regional filters and cancellation", asy expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); }); + +it("sends the series window, regional filters and cancellation", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => ({}) }) as Response); + vi.stubGlobal("fetch", fetcher); + const controller = new AbortController(); + await getStatsSeries(0, 1000, ["YVR", "YYJ"], controller.signal); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/series"); + expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" }); + expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal }); +}); + +it("windows top nodes by since", async () => { + const fetcher = vi.fn(async () => ({ ok: true, json: async () => [] }) as Response); + vi.stubGlobal("fetch", fetcher); + await getTopNodes(["YVR"], 1234, 10); + const url = new URL(fetcher.mock.calls[0]![0] as string); + expect(url.pathname).toContain("/stats/top-nodes"); + expect(Object.fromEntries(url.searchParams)).toEqual({ iatas: "YVR", since: "1234", limit: "10" }); +}); diff --git a/tests/api/client.test.ts b/tests/api/client.test.ts index 66660825..dd2c30d3 100644 --- a/tests/api/client.test.ts +++ b/tests/api/client.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsOverview, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client"; +import { getPackets, getNodesPage, getObserversPage, getScopes, getKnownRoutesPage, searchKnownRoutes, getChannels, getChannelMessagesPage, getTraces, getTraceDetail, getStatsSeries, getTopObservers, getTopAdvertisers, getTopTalkers, getStatsNodeTypes, getClockDrift, getIataBorder, getObserverActivity, isNotFound } from "../../src/api/client"; import type { Feature, Polygon } from "geojson"; import type { NodeSummary } from "../../src/features/nodes/types"; import type { ObserverSummary } from "../../src/features/observers/types"; @@ -402,12 +402,12 @@ describe("getObserversPage", () => { describe("stats endpoints", () => { it("joins the region's IATAs into the iatas param", async () => { - const getUrl = mockFetchOnce({ totalPackets: 0 }); + const getUrl = mockFetchOnce({ hours: [] }); - await getStatsOverview(["YOW", "YYZ"]); + await getStatsSeries(0, 1000, ["YOW", "YYZ"]); const url = new URL(getUrl()); - expect(url.pathname).toContain("/stats/overview"); + expect(url.pathname).toContain("/stats/series"); expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); }); @@ -456,6 +456,16 @@ describe("stats endpoints", () => { expect(url.searchParams.get("iatas")).toBe("YOW,YYZ"); }); + it("sends an empty region as region= so the server returns zeros instead of every IATA", async () => { + const getUrl = mockFetchOnce([]); + + await getStatsNodeTypes({ region: "empty-region" }); + + const url = new URL(getUrl()); + expect(url.searchParams.get("region")).toBe("empty-region"); + expect(url.searchParams.has("iatas")).toBe(false); + }); + it("hits /stats/clock-drift with iatas/limit", async () => { const getUrl = mockFetchOnce([]); @@ -618,3 +628,20 @@ describe("isNotFound", () => { expect(isNotFound(new Error("network down"))).toBe(false); }); }); + +describe("a region with no IATAs", () => { + it("returns empty results without asking the server, which would answer for every IATA", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const empty = { items: [], nextCursor: null, hasMore: false }; + + await expect(getPackets([])).resolves.toEqual(empty); + await expect(getNodesPage([])).resolves.toEqual(empty); + await expect(getObserversPage([])).resolves.toEqual(empty); + await expect(getChannels({ iatas: [] })).resolves.toEqual(empty); + await expect(getChannelMessagesPage(1, { iatas: [] })).resolves.toEqual(empty); + await expect(getTraces([])).resolves.toEqual([]); + await expect(getScopes([])).resolves.toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/api/ws-manager.test.ts b/tests/api/ws-manager.test.ts index e6d8945d..033335b5 100644 --- a/tests/api/ws-manager.test.ts +++ b/tests/api/ws-manager.test.ts @@ -183,6 +183,21 @@ describe("WsManager", () => { ).not.toThrow(); }); + it("drops the subscription without resubscribing for a region with no IATAs", () => { + 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" }); + ws.simulateMessage({ v: 1, type: "subscribed", id: "sub-1", subscriptionId: "s-1" }); + + mgr.updateSubscription({ iatas: [] }); + + const types = ws.sent.slice(1).map((m) => JSON.parse(m).type); + expect(types).toEqual(["unsubscribe"]); + }); + it("updates subscription without reconnecting", () => { const mgr = new WsManager("ws://test/ws"); mgr.connect({ iatas: ["YOW"] }); diff --git a/tests/features/routes/RouteTable.test.tsx b/tests/features/routes/RouteTable.test.tsx index af0278f2..60bb6493 100644 --- a/tests/features/routes/RouteTable.test.tsx +++ b/tests/features/routes/RouteTable.test.tsx @@ -201,3 +201,15 @@ it("translates the search bar, headers and empty state", async () => { expect(screen.getByRole("button", { name: "Rechercher" })).toBeInTheDocument(); expect(await screen.findByText("Aucun trajet")).toBeInTheDocument(); }); + +it("shows no routes for a region with no IATAs", async () => { + vi.mocked(getRegions).mockResolvedValue([{ id: 2, slug: "empty", name: "Empty" }]); + vi.mocked(getRegion).mockResolvedValue({ id: 2, slug: "empty", name: "Empty", iatas: [] }); + mockGetKnownRoutesPage.mockResolvedValue({ items: [{ id: 1, iata: "CCC", hopCount: 1, hops: [], firstSeen: 1, lastSeen: 5, observationCount: 9 }], nextCursor: null, hasMore: false }); + + renderTable({ regions: ["empty"], iatas: [] }); + + await waitFor(() => expect(getRegion).toHaveBeenCalled()); + expect(await screen.findByText("No routes")).toBeInTheDocument(); + expect(screen.queryByText("9")).not.toBeInTheDocument(); +}); diff --git a/tests/features/stats/MeshTab.test.tsx b/tests/features/stats/MeshTab.test.tsx index c8591f5c..f7cf3db2 100644 --- a/tests/features/stats/MeshTab.test.tsx +++ b/tests/features/stats/MeshTab.test.tsx @@ -3,26 +3,33 @@ import { act, render, screen, fireEvent, waitFor, within } from "@testing-librar import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MeshTab } from "../../../src/features/stats/MeshTab"; import i18n from "../../../src/i18n"; -import { getStatsOverview, getStatsObservations, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; -import type { WsManager } from "../../../src/api/ws-manager"; +import { getStatsSeries, getPayloadBreakdown, getTopNodes, getTopObservers, getRadioPresets, getStatsScopes, getStatsNodeTypes } from "../../../src/api/client"; import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; -import type { StatsOverview, StatsRange } from "../../../src/features/stats/types"; +import type { SeriesValues, StatsSeries, StatsRange } from "../../../src/features/stats/types"; -const region = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; -const overview = { totalPackets: 111, totalObservations: 222, activeObservers: 3, activeIatas: 4, windowHours: 24 }; -const points = [1, 2].map((hour) => ({ hour: hour * 3_600_000, iata: "YVR", observationCount: hour * 10, uniquePackets: hour, activeObservers: hour })); +const region: { iatas: string[] | undefined; regionKey: string; isResolved: boolean; emptyRegion?: string } = { iatas: ["YVR"], regionKey: "YVR", isResolved: true }; +const H = 3_600_000; +const values = (v: Partial): SeriesValues => ({ observations: 0, uniquePackets: 0, activeObservers: 0, activeIatas: 0, scopedPackets: 0, activeScopes: 0, maxPathEntries: 0, snrSum: 0, snrSamples: 0, rssiSum: 0, rssiSamples: 0, ...v }); +const series: StatsSeries = { + since: -21 * H, until: 3 * H, revision: 1, earliestComplete: 0, completeHours: 2, + hours: [ + { hour: 0, status: "complete", values: values({ observations: 100, uniquePackets: 50, activeObservers: 2 }) }, + { hour: H, status: "complete", values: values({ observations: 122, uniquePackets: 61, activeObservers: 3 }) }, + { hour: 2 * H, status: "missing", values: null }, + ], + summary: values({ observations: 222, uniquePackets: 111, activeObservers: 3, activeIatas: 4 }), +}; +const withSummary = (v: Partial): StatsSeries => ({ ...series, summary: { ...series.summary, ...v } }); vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); -vi.mock("../../../src/features/stats/useLiveStats", () => ({ useLiveOverview: vi.fn() })); -vi.mock("../../../src/api/client", () => ({ getStatsOverview: vi.fn(), getStatsObservations: vi.fn(), getPayloadBreakdown: vi.fn(), getTopNodes: vi.fn(), getTopObservers: vi.fn(), getRadioPresets: vi.fn(), getStatsScopes: vi.fn(), getStatsNodeTypes: vi.fn() })); +vi.mock("../../../src/api/client", () => ({ getStatsSeries: vi.fn(), getPayloadBreakdown: vi.fn(), getTopNodes: vi.fn(), getTopObservers: vi.fn(), getRadioPresets: vi.fn(), getStatsScopes: vi.fn(), getStatsNodeTypes: vi.fn() })); vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option, onEvents }: { option: EChartsOption; onEvents?: Record void> }) => })); const clients: QueryClient[] = []; beforeEach(() => { - vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; - vi.mocked(getStatsOverview).mockReset().mockResolvedValue(overview); - vi.mocked(getStatsObservations).mockReset().mockResolvedValue(points); + vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = "YVR"; region.isResolved = true; region.emptyRegion = undefined; + vi.mocked(getStatsSeries).mockReset().mockResolvedValue(series); vi.mocked(getPayloadBreakdown).mockReset().mockResolvedValue([{ payloadType: 4, payloadTypeName: "ADVERT", count: 17 }]); - vi.mocked(getTopNodes).mockReset().mockResolvedValue([{ nodeId: "node-id", nodeName: "Old node", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", observationCount: 15, lastHeard: 0 }]); + vi.mocked(getTopNodes).mockReset().mockResolvedValue([{ nodeId: "node-id", publicKey: "abcdef0123456789", nodeName: "Old node", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", observationCount: 15, lastHeard: 0 }]); vi.mocked(getTopObservers).mockReset().mockResolvedValue([{ observerId: "observer-id", displayName: "Old observer", observerType: null, iata: "YVR", observationCount: 16 }]); vi.mocked(getRadioPresets).mockReset().mockResolvedValue([{ preset: "910.525,62.5,7", iata: "YVR", sourceType: "node", count: 18 }]); vi.mocked(getStatsScopes).mockReset().mockResolvedValue([{ name: "#old", packetCount: 19, observerCount: 2, nodeCount: 1 }]); @@ -39,7 +46,7 @@ function deferred() { function mount(range: StatsRange = "24h") { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); const onSelectObserver = vi.fn(); - const view = (range: StatsRange) => ; + const view = (range: StatsRange) => ; const result = render(view(range)); return { client, onSelectObserver, container: result.container, rerender: (next: StatsRange) => result.rerender(view(next)) }; } @@ -48,9 +55,8 @@ async function loaded() { await waitFor(() => expect(screen.getAllByTestId("char it("hides all previous-region values and sparklines until their own requests resolve", async () => { const { rerender, container } = mount(); await loaded(); - const next = deferred(); - vi.mocked(getStatsOverview).mockReturnValue(next.promise); - vi.mocked(getStatsObservations).mockImplementation(() => new Promise(() => {})); + const next = deferred(); + vi.mocked(getStatsSeries).mockReturnValue(next.promise); vi.mocked(getPayloadBreakdown).mockImplementation(() => new Promise(() => {})); vi.mocked(getTopNodes).mockImplementation(() => new Promise(() => {})); vi.mocked(getTopObservers).mockImplementation(() => new Promise(() => {})); @@ -58,59 +64,84 @@ it("hides all previous-region values and sparklines until their own requests res vi.mocked(getStatsScopes).mockImplementation(() => new Promise(() => {})); vi.mocked(getStatsNodeTypes).mockImplementation(() => new Promise(() => {})); region.iatas = ["YOW"]; region.regionKey = "YOW"; rerender("24h"); - await waitFor(() => expect(getStatsOverview).toHaveBeenCalledWith(["YOW"])); + await waitFor(() => expect(getStatsSeries).toHaveBeenCalledWith(expect.any(Number), expect.any(Number), ["YOW"], expect.any(AbortSignal))); expect(within(card("Total packets")).queryByText("111")).not.toBeInTheDocument(); expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); expect(screen.queryAllByTestId("chart")).toHaveLength(0); expect(container.querySelectorAll("polyline")).toHaveLength(0); expect(screen.queryByText("#old")).not.toBeInTheDocument(); expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); - await act(() => next.resolve({ ...overview, totalPackets: 333 })); + await act(() => next.resolve(withSummary({ uniquePackets: 333 }))); await within(card("Total packets")).findByText("333"); - expect(screen.queryAllByTestId("chart")).toHaveLength(0); + expect(screen.getAllByTestId("chart")).toHaveLength(1); + within(card("Observations · 24h")).getByTestId("chart"); }); it("hides pending range charts while retaining fixed-24h KPIs, sparklines and population panels", async () => { const { rerender, container } = mount(); await loaded(); - vi.mocked(getStatsObservations).mockImplementation(() => new Promise(() => {})); + vi.mocked(getStatsSeries).mockImplementation(() => new Promise(() => {})); vi.mocked(getPayloadBreakdown).mockImplementation(() => new Promise(() => {})); vi.mocked(getTopObservers).mockImplementation(() => new Promise(() => {})); + vi.mocked(getTopNodes).mockImplementation(() => new Promise(() => {})); rerender("7d"); expect(within(card("Observations · 7d")).getByText("Loading…")).toBeInTheDocument(); expect(within(card("Top observers · 7d")).getByText("Loading…")).toBeInTheDocument(); expect(within(card("Payload types · 7d")).getByText("Loading…")).toBeInTheDocument(); + expect(within(card("Top nodes · 7d")).getByText("Loading…")).toBeInTheDocument(); expect(screen.queryByText("17 obs")).not.toBeInTheDocument(); - expect(screen.getAllByTestId("chart")).toHaveLength(3); + expect(screen.getAllByTestId("chart")).toHaveLength(2); expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); expect(container.querySelectorAll("polyline")).toHaveLength(2); - expect(screen.getByText("#old")).toBeInTheDocument(); - expect(getStatsOverview).toHaveBeenCalledOnce(); - expect(getStatsObservations).toHaveBeenCalledTimes(2); - expect(getTopNodes).toHaveBeenCalledOnce(); + expect(getStatsSeries).toHaveBeenCalledTimes(2); + expect(getTopNodes).toHaveBeenCalledTimes(2); }); -it("hides failed overview values without discarding the cache or healthy panels, then recovers", async () => { - const { client } = mount(); await loaded(); - vi.mocked(getStatsOverview).mockRejectedValue(new Error("controlled overview failure")); - await act(() => client.refetchQueries({ queryKey: ["stats-overview"] })); +it("hides failed series values and sparklines without discarding the cache or healthy panels, then recovers", async () => { + const { client, container } = mount(); await loaded(); + vi.mocked(getStatsSeries).mockRejectedValue(new Error("controlled series failure")); + await act(() => client.refetchQueries({ queryKey: ["stats-series"] })); await waitFor(() => expect(within(card("Total packets")).queryByText("111")).not.toBeInTheDocument()); expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); - expect(client.getQueryData(["stats-overview", "YVR"])).toEqual(overview); - expect(screen.getAllByTestId("chart")).toHaveLength(6); - vi.mocked(getStatsOverview).mockResolvedValue({ ...overview, totalPackets: 444 }); - await act(() => client.refetchQueries({ queryKey: ["stats-overview"] })); + expect(container.querySelectorAll("polyline")).toHaveLength(0); + expect(client.getQueryData(["stats-series", "YVR", "24h"])).toEqual(series); + expect(within(card("Observations · 24h")).getByText("Failed to load")).toBeInTheDocument(); + expect(screen.getAllByTestId("chart")).toHaveLength(5); + vi.mocked(getStatsSeries).mockResolvedValue(withSummary({ uniquePackets: 444 })); + await act(() => client.refetchQueries({ queryKey: ["stats-series"] })); await within(card("Total packets")).findByText("444"); }); -it("hides only failed 24h sparklines while the selected-range chart and KPI values remain usable", async () => { - const { client, container } = mount("7d"); await loaded(); - expect(container.querySelectorAll("polyline")).toHaveLength(2); - vi.mocked(getStatsObservations).mockRejectedValue(new Error("controlled sparkline failure")); - await act(() => client.refetchQueries({ queryKey: ["stats-observations", "YVR", "24h"], exact: true })); - await waitFor(() => expect(container.querySelectorAll("polyline")).toHaveLength(0)); - expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); - expect(within(card("Observations · 7d")).getByTestId("chart")).toBeInTheDocument(); - expect(client.getQueryData(["stats-observations", "YVR", "24h"])).toEqual(points); +it("draws sparklines from complete hours only", async () => { + const { container } = mount(); await loaded(); + const lines = [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")!.split(" ").length); + expect(lines).toEqual([2, 2]); +}); + +it("breaks sparklines at missing hours instead of joining across them", async () => { + const ok = (n: number) => ({ hour: n * H, status: "complete" as const, values: values({ observations: n + 1, activeObservers: n + 1 }) }); + vi.mocked(getStatsSeries).mockResolvedValue({ ...series, hours: [ok(0), ok(1), { hour: 2 * H, status: "missing", values: null }, ok(3), ok(4)] }); + const { container } = mount(); await loaded(); + const lines = [...container.querySelectorAll("polyline")].map((l) => l.getAttribute("points")!.split(" ")); + expect(lines.map((pts) => pts.length)).toEqual([2, 2, 2, 2]); + expect(lines[1]![0]!.split(",")[0]).toBe("90"); +}); + +it("plots observations and unique packets from the series, leaving gaps for unrolled hours", async () => { + mount("7d"); await loaded(); + const option = JSON.parse(within(card("Observations · 7d")).getByTestId("chart").textContent!); + expect(option.series.map((s: { data: unknown }) => s.data)).toEqual([ + [[0, 100], [H, 122], [2 * H, null]], + [[0, 50], [H, 61], [2 * H, null]], + ]); + expect(getStatsSeries).toHaveBeenCalledTimes(2); + const spans = vi.mocked(getStatsSeries).mock.calls.map((c) => c[1] - c[0]); + expect(spans.sort()).toEqual([24 * H, 7 * 24 * H].sort()); +}); + +it("names deleted top nodes by public key", async () => { + vi.mocked(getTopNodes).mockResolvedValue([{ nodeId: null, publicKey: "deadbeef00112233", nodeName: null, nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", observationCount: 15, lastHeard: 0 }]); + mount(); await loaded(); + expect(within(card("Top nodes · 24h")).getByTestId("chart").textContent).toContain("deadbeef"); }); it("does not report a cached payload total after a failed refresh", async () => { @@ -125,21 +156,20 @@ it("does not report a cached payload total after a failed refresh", async () => it("retains valid values, series, sparklines and observer navigation during same-key background refreshes", async () => { const { client, container, onSelectObserver } = mount(); await loaded(); - const next = deferred(); vi.mocked(getStatsOverview).mockReturnValue(next.promise); + const next = deferred(); vi.mocked(getStatsSeries).mockReturnValue(next.promise); let refresh!: Promise; - await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-overview"] }); }); + await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-series"] }); }); expect(within(card("Total packets")).getByText("111")).toBeInTheDocument(); expect(screen.getAllByTestId("chart")).toHaveLength(6); expect(container.querySelectorAll("polyline")).toHaveLength(2); fireEvent.click(within(card("Top observers · 24h")).getByTestId("chart")); expect(onSelectObserver).toHaveBeenCalledWith("observer-id"); - await act(async () => { next.resolve({ ...overview, totalPackets: 555 }); await refresh; }); + await act(async () => { next.resolve(withSummary({ uniquePackets: 555 })); await refresh; }); await within(card("Total packets")).findByText("555"); }); it("distinguishes initial loading from successful empty results and zero totals", async () => { - const next = deferred(); vi.mocked(getStatsOverview).mockReturnValue(next.promise); - vi.mocked(getStatsObservations).mockResolvedValue([]); + const next = deferred(); vi.mocked(getStatsSeries).mockReturnValue(next.promise); vi.mocked(getPayloadBreakdown).mockResolvedValue([]); vi.mocked(getTopNodes).mockResolvedValue([]); vi.mocked(getTopObservers).mockResolvedValue([]); @@ -150,7 +180,7 @@ it("distinguishes initial loading from successful empty results and zero totals" expect(screen.queryByText("0 obs")).not.toBeInTheDocument(); expect(screen.queryByText("No data")).not.toBeInTheDocument(); expect(within(card("Total packets")).getByText("—")).toBeInTheDocument(); - await act(() => next.resolve({ totalPackets: 0, totalObservations: 0, activeObservers: 0, activeIatas: 0, windowHours: 24 })); + await act(() => next.resolve({ ...series, completeHours: 0, hours: [], summary: values({}) })); await screen.findByText("0 obs"); expect(screen.getAllByText("No data")).toHaveLength(7); expect(within(card("Total packets")).getByText("0")).toBeInTheDocument(); @@ -165,9 +195,9 @@ it("keeps the scope list collapsed until requested", async () => { expect(within(scopeList).getByText("#old")).toBeVisible(); }); -it("labels the KPI cards as the last 24h whatever the selected range", async () => { +it("labels the KPI cards with the rolled 24h window whatever the selected range", async () => { mount("7d"); await loaded(); - expect(screen.getAllByText("Last 24h")).toHaveLength(4); + expect(screen.getAllByText("Last 24h · to 03:00 UTC")).toHaveLength(4); expect(within(card("Active areas")).getByText("4")).toBeInTheDocument(); }); @@ -175,9 +205,9 @@ it("shows French labels for the KPIs, chart titles and scope table", async () => await act(() => i18n.changeLanguage("fr")); mount(); await loaded(); expect(within(card("Paquets totaux")).getByText("111")).toBeInTheDocument(); - expect(screen.getAllByText("Dernières 24 h")).toHaveLength(4); + expect(screen.getAllByText("Dernières 24 h · jusqu’à 03:00 UTC")).toHaveLength(4); expect(screen.getByText("Observations · 24h")).toBeInTheDocument(); - expect(screen.getByText("Meilleurs nœuds · historique complet")).toBeInTheDocument(); + expect(screen.getByText("Meilleurs nœuds · 24h")).toBeInTheDocument(); const typesChart = within(card("Types de nœuds · historique complet")).getByTestId("chart"); expect(typesChart.textContent).toContain("NŒUDS"); fireEvent.click(screen.getByText("Scopes", { exact: true })); @@ -191,8 +221,15 @@ it("names the observation and preset chart series in French", async () => { await act(() => i18n.changeLanguage("fr")); mount(); await loaded(); const obsChart = within(card("Observations · 24h")).getByTestId("chart").textContent; - expect(obsChart).toContain('"Observations","Paquets uniques"'); + expect(obsChart).toContain('"legend":{"data":["Observations","Paquets uniques"]'); const presetChart = within(card("Préréglages radio · historique complet")).getByTestId("chart").textContent; expect(presetChart).toContain('"Nœuds","Observateurs"'); await act(() => i18n.changeLanguage("en")); }); + +it("asks for an empty region by slug rather than falling back to every IATA", async () => { + region.iatas = undefined; region.regionKey = "region:empty"; region.emptyRegion = "empty"; + mount(); await loaded(); + for (const call of vi.mocked(getStatsSeries).mock.calls) expect(call[2]).toEqual({ region: "empty" }); + expect(getStatsNodeTypes).toHaveBeenCalledWith({ region: "empty" }); +}); diff --git a/tests/features/stats/ScopesTab.test.tsx b/tests/features/stats/ScopesTab.test.tsx index b75bcc44..ca555c00 100644 --- a/tests/features/stats/ScopesTab.test.tsx +++ b/tests/features/stats/ScopesTab.test.tsx @@ -14,19 +14,19 @@ beforeEach(() => { vi.clearAllMocks(); query.data = originalData; query.isError it("translates scope counts and membership guidance without changing scope names or values", async () => { await act(() => i18n.changeLanguage("fr")); - render(); + render(); expect(screen.queryByRole("heading")).not.toBeInTheDocument(); const table = screen.getByRole("table", { name: "Effectifs des scopes" }); expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); expect(within(table).getByRole("row", { name: /#east.*2.*2.*3/ })).toBeInTheDocument(); expect(screen.getByText("Observateurs par scope")).toBeInTheDocument(); - expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("pas limités à une période"); + expect(screen.getByText(/plusieurs scopes/)).toHaveTextContent("couvrent la période sélectionnée"); expect(screen.getByText("2 sur 2 scopes")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /Actualiser/ })).not.toBeInTheDocument(); }); it("keeps the current search and chart values when changing language", async () => { - render(); + render(); fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: " EAST " } }); const charts = () => vi.mocked(EChart).mock.calls.slice(-3).map(([{ option }]) => option); const bars = (chart: ReturnType[number]) => (Array.isArray(chart.series) ? chart.series : [chart.series]).map((series) => series?.data); @@ -47,7 +47,7 @@ it("keeps the current search and chart values when changing language", async () it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s and hides stale regional scopes", async (state) => { query[state] = true; await act(() => i18n.changeLanguage("fr")); - render(); + render(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); expect(screen.queryAllByTestId("chart")).toHaveLength(0); expect(screen.queryByText("#west")).not.toBeInTheDocument(); @@ -59,26 +59,26 @@ it.each(["isPending", "isPlaceholderData", "isError"] as const)("translates %s a it("distinguishes a translated empty dataset from a search with no match", async () => { await act(() => i18n.changeLanguage("fr")); - const { rerender } = render(); + const { rerender } = render(); const search = screen.getByRole("searchbox", { name: "Rechercher un scope" }); fireEvent.change(search, { target: { value: "#absent" } }); expect(screen.getByText("Aucun scope ne correspond à cette recherche.")).toBeInTheDocument(); expect(screen.getByText("0 sur 2 scopes")).toBeInTheDocument(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); fireEvent.change(search, { target: { value: "" } }); - query.data = []; rerender(); + query.data = []; rerender(); expect(screen.getByText("Aucune donnée de scope disponible.")).toBeInTheDocument(); expect(screen.getAllByText("Aucune donnée")).toHaveLength(3); expect(screen.queryAllByTestId("chart")).toHaveLength(0); }); it("shows exact scope counts, keeps membership semantics explicit and filters the whole view", () => { - render(); + render(); const table = screen.getByRole("table", { name: "Scope counts" }); expect(within(table).getByRole("row", { name: /#west.*3.*2.*2/ })).toBeInTheDocument(); expect(screen.getByText("Observers by scope")).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /An observer can belong/ })).toBeInTheDocument(); - expect(screen.queryByText(/An observer can belong/, { selector: "p" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: /an observer can belong/ })).toBeInTheDocument(); + expect(screen.queryByText(/an observer can belong/, { selector: "p" })).not.toBeInTheDocument(); expect(screen.getByText(/Scope activity/)).toBeInTheDocument(); fireEvent.change(screen.getByRole("searchbox", { name: "Find a scope" }), { target: { value: "WEST" } }); expect(screen.queryByText("#east")).not.toBeInTheDocument(); @@ -88,10 +88,10 @@ it("shows exact scope counts, keeps membership semantics explicit and filters th it("hides stale values while a region change is pending and on errors", () => { query.isPlaceholderData = true; - const { rerender } = render(); + const { rerender } = render(); expect(screen.queryByRole("table")).not.toBeInTheDocument(); expect(screen.queryAllByTestId("chart")).toHaveLength(0); - query.isPlaceholderData = false; query.isError = true; rerender(); + query.isPlaceholderData = false; query.isError = true; rerender(); expect(screen.getByRole("alert")).toHaveTextContent("Could not load scopes"); expect(screen.queryByText("#west")).not.toBeInTheDocument(); }); diff --git a/tests/features/stats/SectionInfo.test.tsx b/tests/features/stats/SectionInfo.test.tsx index 917e50f4..c7b9520b 100644 --- a/tests/features/stats/SectionInfo.test.tsx +++ b/tests/features/stats/SectionInfo.test.tsx @@ -3,7 +3,6 @@ import { render, screen } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { StatsOverview } from "../../../src/features/stats/StatsOverview"; import { SectionInfo } from "../../../src/features/stats/SectionInfo"; -import type { WsManager } from "../../../src/api/ws-manager"; vi.mock("../../../src/features/stats/ScopesTab", async () => { const { SectionInfo } = await import("../../../src/features/stats/SectionInfo"); @@ -11,7 +10,7 @@ vi.mock("../../../src/features/stats/ScopesTab", async () => { }); it("shows a section's info beside the section chooser", () => { - render(); + render(); const tip = screen.getByRole("button", { name: /About scopes/ }); expect(tip.closest("[data-section-info]")).toBeInTheDocument(); expect(screen.getByRole("main")).not.toContainElement(tip); diff --git a/tests/features/stats/StatsSubHeader.test.tsx b/tests/features/stats/StatsSubHeader.test.tsx index a1c3c68d..e7f8c5d4 100644 --- a/tests/features/stats/StatsSubHeader.test.tsx +++ b/tests/features/stats/StatsSubHeader.test.tsx @@ -19,6 +19,8 @@ describe("translated analytics controls", () => { rerender(); expect(screen.queryByRole("button", { name: "3 j" })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); + rerender(); + expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument(); }); it("keeps section and range identifiers when labels change", async () => { diff --git a/tests/features/stats/TalkersTab.test.tsx b/tests/features/stats/TalkersTab.test.tsx index 0d60f781..c152e9b2 100644 --- a/tests/features/stats/TalkersTab.test.tsx +++ b/tests/features/stats/TalkersTab.test.tsx @@ -8,7 +8,7 @@ import type { EChartsOption } from "../../../src/features/stats/echarts-setup"; import i18n from "../../../src/i18n"; const region = { iatas: ["YVR"], regionKey: "YVR" }; -const advertisers: TopAdvertiser[] = [{ nodeId: "fixture-node", nodeName: "Old advertiser", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", advertCount: 14, floodAdvertCount: 10, directAdvertCount: 4, lastHeard: 0 }]; +const advertisers: TopAdvertiser[] = [{ nodeId: "fixture-node", publicKey: "aa11bb22cc33dd44", nodeName: "Old advertiser", nodeType: 2, nodeTypeName: "Repeater", iata: "YVR", advertCount: 14, floodAdvertCount: 10, directAdvertCount: 4, lastHeard: 0 }]; const talkers: TopTalker[] = [{ senderName: "Old sender", messageCount: 42, lastSent: 0 }]; vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => region })); vi.mock("../../../src/api/client", () => ({ getTopAdvertisers: vi.fn(), getTopTalkers: vi.fn() })); @@ -127,6 +127,14 @@ it("opens the clicked advertiser's node", async () => { expect(onViewNode).toHaveBeenCalledWith("fixture-node"); }); +it("lists a deleted node by public key without opening it", async () => { + vi.mocked(getTopAdvertisers).mockResolvedValue([...advertisers, { ...advertisers[0]!, nodeId: null, publicKey: "deadbeef00112233", nodeName: null }]); + const onViewNode = vi.fn(); + mount(onViewNode); + fireEvent.click(await screen.findByText("deadbeef")); + expect(onViewNode).not.toHaveBeenCalled(); +}); + it("renders the leaderboards in French", async () => { await i18n.changeLanguage("fr"); mount(); diff --git a/tests/features/stats/analytics-navigation.test.tsx b/tests/features/stats/analytics-navigation.test.tsx index d72d28dc..00f79be7 100644 --- a/tests/features/stats/analytics-navigation.test.tsx +++ b/tests/features/stats/analytics-navigation.test.tsx @@ -2,7 +2,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; import { expect, it, vi } from "vitest"; import { StatsOverview } from "../../../src/features/stats/StatsOverview"; -import type { WsManager } from "../../../src/api/ws-manager"; vi.mock("../../../src/features/stats/TrafficTab", () => ({ TrafficTab: ({ range }: { range: string }) =>

Traffic range {range}

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

Signal range {range}

})); @@ -12,19 +11,19 @@ vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () =>

Sc function Location() { return {useLocation().search}; } it("keeps seven-day links and falls back from retired 3d links", () => { - const { unmount } = render(); + const { unmount } = render(); expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "7d" })).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByRole("button", { name: "3d" })).not.toBeInTheDocument(); unmount(); - render(); + render(); expect(screen.getByText("Signal range 7d")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "30d" })).toBeInTheDocument(); expect(screen.queryByText(/Hourly analytics keep summaries for up to 30 days/)).not.toBeInTheDocument(); }); it("opens Paths & Hashes from a shared URL and retains the region while changing range", () => { - render(); + render(); expect(screen.getByText("Paths range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Paths & hashes" })).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByRole("button", { name: "30d" })); @@ -33,7 +32,7 @@ it("opens Paths & Hashes from a shared URL and retains the region while changing }); it("opens Signal from a shared URL and retains regional state when changing range", () => { - render(); + render(); expect(screen.getByText("Signal range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "RF / Signal" })).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByRole("button", { name: "7d" })); @@ -42,7 +41,7 @@ it("opens Signal from a shared URL and retains regional state when changing rang }); it("opens Traffic from a shared URL and keeps unrelated selections when changing the range", () => { - render(); + render(); expect(screen.getByText("Traffic range 24h")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Traffic" })).toHaveAttribute("aria-pressed", "true"); fireEvent.click(screen.getByRole("button", { name: "30d" })); @@ -52,10 +51,10 @@ it("opens Traffic from a shared URL and keeps unrelated selections when changing expect(screen.getByText("Mesh charts")).toBeInTheDocument(); }); -it("opens Scopes without a misleading range control and preserves the chosen range for Traffic", () => { - render(); +it("opens Scopes with the range control and preserves the chosen range for Traffic", () => { + render(); expect(screen.getByText("Scope charts")).toBeInTheDocument(); - expect(screen.queryByRole("group", { name: "Time range" })).not.toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Time range" })).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Traffic" })); expect(screen.getByText("Traffic range 30d")).toBeInTheDocument(); expect(screen.getByLabelText("Analytics URL")).toHaveTextContent("iata=YOW"); diff --git a/tests/features/stats/scope-query.test.tsx b/tests/features/stats/scope-query.test.tsx index 02b2b0f4..433af777 100644 --- a/tests/features/stats/scope-query.test.tsx +++ b/tests/features/stats/scope-query.test.tsx @@ -15,10 +15,10 @@ afterEach(() => { vi.clearAllMocks(); region.iatas = ["YVR"]; region.regionKey = it("sends the selected IATAs and fetches a separate global query when the filter is cleared", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const { rerender, unmount } = renderHook(() => useScopes(), { wrapper: ({ children }: { children: ReactNode }) => {children} }); - await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(["YVR"], expect.any(AbortSignal))); + const { rerender, unmount } = renderHook(() => useScopes("7d"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(["YVR"], expect.any(Number), expect.any(AbortSignal))); region.iatas = undefined; region.regionKey = "*"; rerender(); - await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(undefined, expect.any(AbortSignal))); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(undefined, expect.any(Number), expect.any(AbortSignal))); expect(client.getQueryCache().findAll({ queryKey: ["stats-scopes"] })).toHaveLength(2); unmount(); client.clear(); }); @@ -26,19 +26,30 @@ it("sends the selected IATAs and fetches a separate global query when the filter it("does not fetch a global fallback for unresolved selected regions", () => { region.isResolved = false; const client = new QueryClient(); - const { unmount } = renderHook(() => useScopes(), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + const { unmount } = renderHook(() => useScopes("7d"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); expect(getStatsScopes).not.toHaveBeenCalled(); unmount(); client.clear(); }); -it("reuses the regional query without a time-window key when only the language changes", async () => { +it("reuses the regional query when only the language changes", async () => { const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const { unmount } = render(); + const { unmount } = render(); await screen.findByText("No scope data available."); expect(getStatsScopes).toHaveBeenCalledOnce(); await act(() => i18n.changeLanguage("fr")); expect(screen.getByText("Aucune donnée de scope disponible.")).toBeInTheDocument(); expect(getStatsScopes).toHaveBeenCalledOnce(); - expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-scopes", "YVR"]]); + expect(client.getQueryCache().getAll().map((query) => query.queryKey)).toEqual([["stats-scopes", "YVR", "7d"]]); + unmount(); client.clear(); +}); + +it("windows scope counts by the selected range", async () => { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const before = Date.now(); + const { unmount } = renderHook(() => useScopes("24h"), { wrapper: ({ children }: { children: ReactNode }) => {children} }); + await waitFor(() => expect(getStatsScopes).toHaveBeenCalledOnce()); + const since = vi.mocked(getStatsScopes).mock.calls[0]![1]!; + expect(since).toBeGreaterThanOrEqual(before - 24 * 3_600_000); + expect(since).toBeLessThanOrEqual(Date.now() - 24 * 3_600_000); unmount(); client.clear(); }); diff --git a/tests/features/stats/series-window.test.ts b/tests/features/stats/series-window.test.ts new file mode 100644 index 00000000..5809e2fd --- /dev/null +++ b/tests/features/stats/series-window.test.ts @@ -0,0 +1,11 @@ +import { expect, it } from "vitest"; +import { rolledWindow } from "../../../src/features/stats/useStats"; + +const H = 3_600_000; + +it("ends the window at the newest hour the server can have rolled", () => { + // the hour starting at H becomes rollable at H + 95 min, matching the server's overview window + expect(rolledWindow("24h", 12 * H + 34 * 60_000)).toEqual({ since: -13 * H, until: 11 * H }); + expect(rolledWindow("24h", 12 * H + 35 * 60_000)).toEqual({ since: -12 * H, until: 12 * H }); + expect(rolledWindow("7d", 12 * H + 35 * 60_000)).toEqual({ since: 12 * H - 7 * 24 * H, until: 12 * H }); +}); diff --git a/tests/features/stats/useLiveStats.test.tsx b/tests/features/stats/useLiveStats.test.tsx index 9c0f64b9..c50cced8 100644 --- a/tests/features/stats/useLiveStats.test.tsx +++ b/tests/features/stats/useLiveStats.test.tsx @@ -1,90 +1,10 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { describe, it, expect } from "vitest"; import { renderHook } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; -import { useLiveOverview, useLiveObserver } from "../../../src/features/stats/useLiveStats"; -import type { StatsOverview } from "../../../src/features/stats/types"; +import { useLiveObserver } from "../../../src/features/stats/useLiveStats"; import type { WsManager } from "../../../src/api/ws-manager"; -import type { WsObserverStatus, WsPacketObservation } from "../../../src/types/ws"; - -vi.mock("../../../src/hooks/useRegion", () => ({ - useRegion: () => ({ iatas: ["YOW"], regionKey: "YOW" }), -})); - -const overview: StatsOverview = { - totalPackets: 100, - totalObservations: 200, - activeObservers: 5, - activeIatas: 2, - windowHours: 24, -}; - -// Captures the packet handler the hook registers and lets the test push events through it. -function fakeManager() { - const handlers: Array<(d: WsPacketObservation["data"]) => void> = []; - const manager = { - onPacketObservation: (h: (d: WsPacketObservation["data"]) => void) => { - handlers.push(h); - return () => {}; - }, - } as unknown as WsManager; - return { manager, emit: (d: WsPacketObservation["data"]) => handlers.forEach((h) => h(d)) }; -} - -describe("useLiveOverview", () => { - let rafCallbacks: FrameRequestCallback[]; - - beforeEach(() => { - rafCallbacks = []; - vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => { - rafCallbacks.push(cb); - return rafCallbacks.length; - }); - vi.stubGlobal("cancelAnimationFrame", () => {}); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it("bumps the cached stats-overview counters on packet observations", () => { - const qc = new QueryClient(); - qc.setQueryData(["stats-overview", "YOW"], overview); - const { manager, emit } = fakeManager(); - - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - renderHook(() => useLiveOverview(manager), { wrapper }); - - emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]); - emit({ packet: { isFirstObservation: false } } as WsPacketObservation["data"]); - rafCallbacks.forEach((cb) => cb(0)); // flush the coalesced frame - - const after = qc.getQueryData(["stats-overview", "YOW"]); - expect(after?.totalPackets).toBe(101); - expect(after?.totalObservations).toBe(202); - }); - - it("drops pending deltas instead of resurrecting a failed overview query", () => { - const qc = new QueryClient(); - qc.setQueryData(["stats-overview", "YOW"], overview); - qc.getQueryCache().find({ queryKey: ["stats-overview", "YOW"] })?.setState({ status: "error", error: new Error("refetch failed") }); - const { manager, emit } = fakeManager(); - - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - renderHook(() => useLiveOverview(manager), { wrapper }); - - emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]); - rafCallbacks.forEach((cb) => cb(0)); - - const state = qc.getQueryState(["stats-overview", "YOW"]); - expect(state?.status).toBe("error"); - expect(state?.data).toEqual(overview); - }); -}); +import type { WsObserverStatus } from "../../../src/types/ws"; describe("useLiveObserver", () => { it("refreshes the selected observer's telemetry, but not its heard activity, on a status event", () => { diff --git a/tests/features/traces/TraceList.test.tsx b/tests/features/traces/TraceList.test.tsx index efa47378..ae0ded02 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -175,6 +175,14 @@ describe("TraceList", () => { await waitFor(() => expect(mockGetTraces.mock.calls.at(-1)?.[1]).toMatchObject({ type: "PING" })); }); + it("explains that the type filter classifies whole tags", async () => { + mockGetTraces.mockResolvedValue([]); + + renderTraces(); + + expect(await screen.findByText(/TRACE if any packet in the tag is multi-hop/)).toBeInTheDocument(); + }); + it("shows an empty state when there are no traces", async () => { mockGetTraces.mockResolvedValue([]); diff --git a/tests/hooks/region-selection.test.ts b/tests/hooks/region-selection.test.ts index cd94df55..8b66ace5 100644 --- a/tests/hooks/region-selection.test.ts +++ b/tests/hooks/region-selection.test.ts @@ -3,6 +3,7 @@ import { ALL_REGIONS, isAllRegions, resolveIatas, + emptyRegionSlug, regionKey, parseSelection, selectionToParams, @@ -14,6 +15,7 @@ import { const regionIatas = new Map([ ["western-canada", ["YVR", "YYJ"]], ["cascadia", ["YVR", "SEA"]], // intentionally overlaps YVR with western-canada + ["empty", []], ]); describe("isAllRegions", () => { @@ -117,3 +119,16 @@ describe("serialize/deserialize", () => { expect(deserializeSelection('{"regions":"oops"}')).toEqual(ALL_REGIONS); }); }); + +describe("emptyRegionSlug", () => { + it("names the selected region when it resolves to no IATAs", () => { + expect(emptyRegionSlug({ regions: ["empty"], iatas: [] }, regionIatas)).toBe("empty"); + }); + + it("is undefined for all regions, a populated selection, or regions still loading", () => { + expect(emptyRegionSlug(ALL_REGIONS, regionIatas)).toBeUndefined(); + expect(emptyRegionSlug({ regions: ["empty", "cascadia"], iatas: [] }, regionIatas)).toBeUndefined(); + expect(emptyRegionSlug({ regions: ["empty"], iatas: ["YVR"] }, regionIatas)).toBeUndefined(); + expect(emptyRegionSlug({ regions: ["empty", "unloaded"], iatas: [] }, regionIatas)).toBeUndefined(); + }); +});