mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 15:23:27 +00:00
Serve analytics from hourly rollups and handle empty regions (#132)
This commit is contained in:
1 parent
f9ffb564c7
commit
39e921c277
38 files changed
+507
-391
No files matched your search
+1
-1
@@ -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
@@ -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(
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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 && <>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 />}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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")}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"] });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user