Serve analytics from hourly rollups and handle empty regions (#132)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-02 08:03:00 -04:00
1 parent f9ffb564c7
commit 39e921c277
38 files changed
+507 -391

No files matched your search

+1 -1
View File
@@ -313,7 +313,7 @@ function AppInner() {
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
Traces: <TraceList onAnalyze={hash => { if (hash) viewPacket(hash); }} onViewNode={viewNode} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Analytics: <StatsOverview wsManager={wsManager} onViewNode={viewNode} />,
Analytics: <StatsOverview onViewNode={viewNode} />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
};
+47 -29
View File
@@ -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<T>(path: string, params?: Record<string, string | number
// endpoint functions
export function getObserverComparison(
iatas: string[] | undefined,
iatas: StatsRegion,
params: { observerA: string; observerB: string; since: number; until: number },
signal?: AbortSignal,
): Promise<ObserverComparison> {
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 = <T>(): CursorPage<T> => ({ 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<CursorPage<PacketSummary>> {
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<Region> {
// 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<ChannelPage> {
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<CursorPage<ChannelMessage>> {
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<BrokerStatus[]> {
// 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<string[]> {
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<TraceTagSummary[]> {
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<CursorPage<NodeSummary>> {
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<CursorPage<ObserverSummary>> {
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<NodeNeighbor[]> {
// stats endpoints
export function getStatsOverview(iatas?: string[]): Promise<StatsOverview> {
return request("/stats/overview", { iatas: iatasParam(iatas) });
export function getStatsSeries(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise<StatsSeries> {
return request("/stats/series", { since, until, ...statsRegionParams(iatas) }, signal);
}
export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal);
export function getStatsObservations(iatas?: StatsRegion, since?: number, signal?: AbortSignal): Promise<ObservationPoint[]> {
return request("/stats/observations", { ...statsRegionParams(iatas), since }, signal);
}
export function getSignalStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise<SignalStats> {
return request("/stats/signal", { since, until, iatas: iatasParam(iatas) }, signal);
export function getSignalStats(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise<SignalStats> {
return request("/stats/signal", { since, until, ...statsRegionParams(iatas) }, signal);
}
export function getPathStats(since: number, until: number, iatas?: string[], signal?: AbortSignal): Promise<PathStats> {
return request("/stats/paths", { since, until, iatas: iatasParam(iatas) }, signal);
export function getPathStats(since: number, until: number, iatas?: StatsRegion, signal?: AbortSignal): Promise<PathStats> {
return request("/stats/paths", { since, until, ...statsRegionParams(iatas) }, signal);
}
export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since });
export function getPayloadBreakdown(iatas?: StatsRegion, since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { ...statsRegionParams(iatas), since });
}
export function getTopNodes(iatas?: string[], limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iatas: iatasParam(iatas), limit });
export function getTopNodes(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { ...statsRegionParams(iatas), since, limit });
}
export function getTopObservers(iatas?: string[], since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iatas: iatasParam(iatas), since, limit });
export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { ...statsRegionParams(iatas), since, limit });
}
export function getTopAdvertisers(iatas?: string[], since?: number, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { iatas: iatasParam(iatas), since, limit });
export function getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, limit });
}
export function getTopTalkers(iatas?: string[], since?: number, limit = 10): Promise<TopTalker[]> {
return request("/stats/top-talkers", { iatas: iatasParam(iatas), since, limit });
export function getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopTalker[]> {
return request("/stats/top-talkers", { ...statsRegionParams(iatas), since, limit });
}
export function getRadioPresets(iatas?: string[]): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iatas: iatasParam(iatas) });
export function getRadioPresets(iatas?: StatsRegion): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { ...statsRegionParams(iatas) });
}
export function getStatsNodeTypes(iatas?: string[]): Promise<NodeTypeCount[]> {
return request("/stats/node-types", { iatas: iatasParam(iatas) });
export function getStatsNodeTypes(iatas?: StatsRegion): Promise<NodeTypeCount[]> {
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<ClockDriftEntry[]> {
return request("/stats/clock-drift", { iatas: iatasParam(iatas), limit });
export function getClockDrift(iatas?: StatsRegion, limit = 100): Promise<ClockDriftEntry[]> {
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<ScopeStats[]> {
return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal);
export function getStatsScopes(iatas?: StatsRegion, since?: number, signal?: AbortSignal): Promise<ScopeStats[]> {
return request("/stats/scopes", { ...statsRegionParams(iatas), since }, signal);
}
export function getObserverTelemetry(
+2 -1
View File
@@ -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({
+2 -2
View File
@@ -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);
+3 -3
View File
@@ -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<Sort>("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,
});
+1 -1
View File
@@ -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]);
+5 -3
View File
@@ -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() {
</Card>
{selection && <Card title={t("observerCompare.floodHeard")}>
<p className="mb-2 break-words text-sm text-text-normal">{t("observerCompare.pair", { a: observerA.data?.displayName ?? selection.observerA, b: observerB.data?.displayName ?? selection.observerB })}</p>
<p className="mb-3 flex flex-wrap items-center gap-x-2 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas?.join(", ") || t("observerCompare.allRegions")}<InfoTip text={t("observerCompare.localTimeNote")} /></p>
<p className="mb-3 flex flex-wrap items-center gap-x-2 break-words text-sm text-text-muted">{new Date(selection.since).toLocaleString()} – {new Date(selection.until).toLocaleString()} · {iatas ? iatas.join(", ") || emptyRegion : t("observerCompare.allRegions")}<InfoTip text={t("observerCompare.localTimeNote")} /></p>
{result.isFetching && <p role="status" className="text-sm text-text-muted">{t("observerCompare.comparing")}</p>}
{result.isError && <div role="alert" className="text-sm text-danger"><p>{result.error.message}</p><button type="button" onClick={() => void result.refetch()} className="mt-2 text-primary">{t("observerCompare.retryComparison")}</button></div>}
{data && !result.isError && <>
+29 -43
View File
@@ -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<T>(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<number, { hour: number; observationCount: number; uniquePackets: number; activeObservers: number }>();
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 (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(ov?.totalPackets)} />
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={ov?.activeObservers ?? "—"} spark={observerSpark} />
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={ov?.activeIatas ?? "—"} />
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(ov?.summary.uniquePackets)} />
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(ov?.summary.observations)} spark={obsSpark} />
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={ov?.summary.activeObservers ?? "—"} spark={observerSpark} />
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={ov?.summary.activeIatas ?? "—"} />
</div>
<ChartCard
@@ -144,7 +131,7 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
option={obsOption}
isLoading={observations.isPending || observations.isPlaceholderData}
isError={observations.isError}
isEmpty={obs.length === 0}
isEmpty={!observationsData?.completeHours}
/>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
@@ -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 */}
<ChartCard title={t("mesh.topNodes")} height={208} option={nodesOption} isLoading={topNodes.isPending || topNodes.isPlaceholderData} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<ChartCard title={t("mesh.topNodes", { range })} height={208} option={nodesOption} isLoading={topNodes.isPending || topNodes.isPlaceholderData} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<ChartCard title={t("mesh.nodeTypes")} height={208} option={typesOption} isLoading={nodeTypes.isPending || nodeTypes.isPlaceholderData} isError={nodeTypes.isError} isEmpty={typeRows.length === 0} />
<ChartCard title={t("mesh.radioPresets")} height={208} option={presetsOption} isLoading={radioPresets.isPending || radioPresets.isPlaceholderData} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
+3 -2
View File
@@ -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;
+3 -5
View File
@@ -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) {
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} infoSlot={setInfoSlot} />
<SectionInfoSlot.Provider value={infoSlot}>
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />}
{tab === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} />}
{tab === "traffic" && <TrafficTab range={range} />}
{tab === "signal" && <SignalTab range={range} />}
{tab === "paths" && <PathsTab range={range} />}
{tab === "scopes" && <ScopesTab />}
{tab === "scopes" && <ScopesTab range={range} />}
{tab === "talkers" && <TalkersTab range={range} onViewNode={onViewNode} />}
{tab === "clockdrift" && <ClockDriftTab />}
{tab === "graph" && <NeighbourGraphTab />}
+1 -1
View File
@@ -132,7 +132,7 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, infoSlo
<span ref={infoSlot} data-section-info className="flex shrink-0 empty:hidden" />
</div>
{/* 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" && (
<Segmented
className="shrink-0"
options={rangeOptions}
+4 -4
View File
@@ -48,13 +48,13 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
cell: (a) => (
<div className="flex min-w-0 items-center gap-2">
<span className={`truncate ${a.nodeName ? "text-text-normal" : "italic text-text-dim"}`}>
{a.nodeName ?? a.nodeId.slice(0, 8)}
{a.nodeName ?? a.publicKey.slice(0, 8)}
</span>
<Badge variant="default">{a.nodeTypeName}</Badge>
<IataChip>{a.iata}</IataChip>
</div>
),
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) {
<DataTable
columns={advertiserColumns}
rows={advertisers}
rowKey={(a) => 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")}
/>
+15 -9
View File
@@ -26,19 +26,25 @@ export function Card({
);
}
function Sparkline({ values, color }: { values: number[]; color: string }) {
if (values.length < 2) return <div className="mt-1.5 h-[20px]" />;
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 <div className="mt-1.5 h-[20px]" />;
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 (
<svg width="100%" height={h} viewBox={`0 0 ${w} ${h}`} preserveAspectRatio="none" className="mt-1.5" aria-hidden>
<polyline fill="none" stroke={color} strokeWidth="1.5" points={pts} />
{runs.filter((run) => run.length > 1).map((run) => (
<polyline key={run[0]} fill="none" stroke={color} strokeWidth="1.5" points={run.join(" ")} />
))}
</svg>
);
}
@@ -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 (
+4 -5
View File
@@ -29,13 +29,12 @@ function valueAxis(c: ChartColors, extra: Record<string, unknown> = {}) {
// ---- 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 },
},
+30 -8
View File
@@ -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;
+4 -53
View File
@@ -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<number | null>(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<StatsOverview>(["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.
+50 -31
View File
@@ -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,
+5 -4
View File
@@ -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<T>(
key: string,
range: StatsRange,
fetch: (since: number, until: number, iatas: string[] | undefined, signal?: AbortSignal) => Promise<T>,
fetch: (since: number, until: number, where: StatsRegion, signal?: AbortSignal) => Promise<T>,
) {
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<T>(
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,
+10 -6
View File
@@ -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) {
<span className="font-mono text-[11px] text-text-dim">
{tags ? t("traces.tagCount", { count: tags.length }) : ""}
</span>
<Segmented
options={typeOptions}
value={typeFilter}
onChange={(v) => setTypeFilter(v as "" | TraceType)}
ariaLabel={t("traces.typeLabel")}
/>
<div className="flex items-center gap-2">
<InfoTip text={t("traces.typeHint")} />
<Segmented
options={typeOptions}
value={typeFilter}
onChange={(v) => setTypeFilter(v as "" | TraceType)}
ariaLabel={t("traces.typeLabel")}
/>
</div>
</div>
<div className="flex-1 overflow-y-auto p-3 flex flex-col gap-2">
{isLoading ? (
+10
View File
@@ -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, string[]>,
): 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(",") : "*";
+10 -2
View File
@@ -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]);
}
+4 -2
View File
@@ -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",
+4 -2
View File
@@ -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",
+26 -4
View File
@@ -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<typeof fetch>(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<typeof fetch>(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<typeof fetch>(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" });
});
+31 -4
View File
@@ -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();
});
});
+15
View File
@@ -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"] });
+12
View File
@@ -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();
});
+90 -53
View File
@@ -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>): 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<SeriesValues>): 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<string, (p: unknown) => void> }) => <button data-testid="chart" onClick={() => onEvents?.click?.({ dataIndex: 0 })}>{JSON.stringify(option)}</button> }));
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<T>() {
function mount(range: StatsRange = "24h") {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client);
const onSelectObserver = vi.fn();
const view = (range: StatsRange) => <QueryClientProvider client={client}><MeshTab range={range} onSelectObserver={onSelectObserver} wsManager={{} as WsManager} /></QueryClientProvider>;
const view = (range: StatsRange) => <QueryClientProvider client={client}><MeshTab range={range} onSelectObserver={onSelectObserver} /></QueryClientProvider>;
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<StatsOverview>();
vi.mocked(getStatsOverview).mockReturnValue(next.promise);
vi.mocked(getStatsObservations).mockImplementation(() => new Promise(() => {}));
const next = deferred<StatsSeries>();
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<StatsOverview>(); vi.mocked(getStatsOverview).mockReturnValue(next.promise);
const next = deferred<StatsSeries>(); vi.mocked(getStatsSeries).mockReturnValue(next.promise);
let refresh!: Promise<void>;
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<StatsOverview>(); vi.mocked(getStatsOverview).mockReturnValue(next.promise);
vi.mocked(getStatsObservations).mockResolvedValue([]);
const next = deferred<StatsSeries>(); 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" });
});
+11 -11
View File
@@ -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(<ScopesTab />);
render(<ScopesTab range="24h" />);
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(<ScopesTab />);
render(<ScopesTab range="24h" />);
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<typeof charts>[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(<ScopesTab />);
render(<ScopesTab range="24h" />);
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(<ScopesTab />);
const { rerender } = render(<ScopesTab range="24h" />);
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(<ScopesTab />);
query.data = []; rerender(<ScopesTab range="24h" />);
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(<ScopesTab />);
render(<ScopesTab range="24h" />);
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(<ScopesTab />);
const { rerender } = render(<ScopesTab range="24h" />);
expect(screen.queryByRole("table")).not.toBeInTheDocument();
expect(screen.queryAllByTestId("chart")).toHaveLength(0);
query.isPlaceholderData = false; query.isError = true; rerender(<ScopesTab />);
query.isPlaceholderData = false; query.isError = true; rerender(<ScopesTab range="24h" />);
expect(screen.getByRole("alert")).toHaveTextContent("Could not load scopes");
expect(screen.queryByText("#west")).not.toBeInTheDocument();
});
+1 -2
View File
@@ -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(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=scopes"]}><StatsOverview wsManager={{} as WsManager} /></MemoryRouter>);
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=scopes"]}><StatsOverview /></MemoryRouter>);
const tip = screen.getByRole("button", { name: /About scopes/ });
expect(tip.closest("[data-section-info]")).toBeInTheDocument();
expect(screen.getByRole("main")).not.toContainElement(tip);
@@ -19,6 +19,8 @@ describe("translated analytics controls", () => {
rerender(<StatsSubHeader {...props} tab="traffic" range="7d" />);
expect(screen.queryByRole("button", { name: "3 j" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument();
rerender(<StatsSubHeader {...props} tab="scopes" range="7d" />);
expect(screen.getByRole("button", { name: "30 j" })).toBeInTheDocument();
});
it("keeps section and range identifiers when labels change", async () => {
+9 -1
View File
@@ -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();
@@ -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 }) => <p>Traffic range {range}</p> }));
vi.mock("../../../src/features/stats/SignalTab", () => ({ SignalTab: ({ range }: { range: string }) => <p>Signal range {range}</p> }));
@@ -12,19 +11,19 @@ vi.mock("../../../src/features/stats/ScopesTab", () => ({ ScopesTab: () => <p>Sc
function Location() { return <output aria-label="Analytics URL">{useLocation().search}</output>; }
it("keeps seven-day links and falls back from retired 3d links", () => {
const { unmount } = render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=7d"]}><StatsOverview wsManager={{} as WsManager} /></MemoryRouter>);
const { unmount } = render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=7d"]}><StatsOverview /></MemoryRouter>);
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(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=3d"]}><StatsOverview wsManager={{} as WsManager} /></MemoryRouter>);
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=3d"]}><StatsOverview /></MemoryRouter>);
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(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=paths&range=24h&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=paths&range=24h&iata=YOW"]}><StatsOverview /><Location /></MemoryRouter>);
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(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=24h&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=signal&range=24h&iata=YOW"]}><StatsOverview /><Location /></MemoryRouter>);
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(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=traffic&range=24h&observerId=kept"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=traffic&range=24h&observerId=kept"]}><StatsOverview /><Location /></MemoryRouter>);
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(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=scopes&range=30d&iata=YOW"]}><StatsOverview wsManager={{} as WsManager} /><Location /></MemoryRouter>);
it("opens Scopes with the range control and preserves the chosen range for Traffic", () => {
render(<MemoryRouter initialEntries={["/?tab=Analytics&statsTab=scopes&range=30d&iata=YOW"]}><StatsOverview /><Location /></MemoryRouter>);
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");
+18 -7
View File
@@ -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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
await waitFor(() => expect(getStatsScopes).toHaveBeenCalledWith(["YVR"], expect.any(AbortSignal)));
const { rerender, unmount } = renderHook(() => useScopes("7d"), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
const { unmount } = renderHook(() => useScopes("7d"), { wrapper: ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
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(<QueryClientProvider client={client}><ScopesTab /></QueryClientProvider>);
const { unmount } = render(<QueryClientProvider client={client}><ScopesTab range="7d" /></QueryClientProvider>);
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 }) => <QueryClientProvider client={client}>{children}</QueryClientProvider> });
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();
});
@@ -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 });
});
+3 -83
View File
@@ -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<StatsOverview>(["stats-overview", "YOW"], overview);
const { manager, emit } = fakeManager();
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
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<StatsOverview>(["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<StatsOverview>(["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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
renderHook(() => useLiveOverview(manager), { wrapper });
emit({ packet: { isFirstObservation: true } } as WsPacketObservation["data"]);
rafCallbacks.forEach((cb) => cb(0));
const state = qc.getQueryState<StatsOverview>(["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", () => {
+8
View File
@@ -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([]);
+15
View File
@@ -3,6 +3,7 @@ import {
ALL_REGIONS,
isAllRegions,
resolveIatas,
emptyRegionSlug,
regionKey,
parseSelection,
selectionToParams,
@@ -14,6 +15,7 @@ import {
const regionIatas = new Map<string, string[]>([
["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();
});
});