feat(stats): add Mesh + Observer stats tab with ECharts

Integrate the stats feature onto main: live mesh KPIs, observations
time-series, payload/node-type donuts, top-nodes/observers leaderboards,
radio presets, scope stats, and per-observer telemetry (battery, airtime,
noise, queue, receive errors) with 1h/6h/24h bucketing.

- adapt to the multi-IATA region model (useRegion -> single iata for /stats/*)
- rename stats getScopes -> getStatsScopes (avoids /scopes name-list collision)
- donut legend kept right but scrollable + truncating so it never clips
- balanced mesh grid; observer empty-state for telemetry-less observers
- normalize telemetry t to ms (raw path emits seconds, bucketed emits ms)
- add echarts 5.5.1; lazy-load StatsOverview chunk
This commit is contained in:
MrAlders0n
2026-06-09 22:02:27 -04:00
parent ed9741ea63
commit be072bb42c
23 changed files with 1934 additions and 291 deletions
+328 -286
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -20,6 +20,7 @@
"@nazka/map-gl-js-spiderfy": "^2.0.0",
"@tanstack/react-query": "^5.100.11",
"@tanstack/react-virtual": "^3.13.25",
"echarts": "5.5.1",
"maplibre-gl": "^5.24.0",
"react": "^19.2.6",
"react-dom": "^19.2.6",
+4 -2
View File
@@ -25,7 +25,6 @@ import { ObserverTable } from "./features/observers/ObserverTable";
import { RouteTable } from "./features/routes/RouteTable";
import { TraceList } from "./features/traces/TraceList";
import { ChannelList } from "./features/channels/ChannelList";
import { StatsOverview } from "./features/stats/StatsOverview";
import { EmptyState } from "./components/EmptyState";
import { getPacketDetail } from "./api/client";
import { WsManager } from "./api/ws-manager";
@@ -35,6 +34,9 @@ import { WS_URL } from "./lib/constants";
// first time someone opens the Map tab instead of bloating the initial bundle.
const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView })));
// Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats.
const StatsOverview = lazy(() => import("./features/stats/StatsOverview").then((m) => ({ default: m.StatsOverview })));
// global singletons
const queryClient = new QueryClient({
@@ -192,7 +194,7 @@ function AppInner() {
// master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses
Traces: <TraceList onAnalyze={setOverlayPacketHash} onViewNode={setOverlayNodeId} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Stats: <StatsOverview />,
Stats: <StatsOverview wsManager={wsManager} />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
};
+52
View File
@@ -3,6 +3,16 @@ import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary,
import type { ChannelSummary, ChannelMessage } from "../features/channels/types";
import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types";
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
import type {
StatsOverview,
ObservationPoint,
PayloadBreakdownItem,
TopNode,
TopObserver,
RadioPreset,
ScopeStats,
ObserverTelemetry,
} from "../features/stats/types";
// typed fetch wrapper with query params
@@ -239,4 +249,46 @@ export function getNodeNeighbors(nodeId: string): Promise<NodeNeighbor[]> {
return request(`/nodes/${nodeId}/neighbors`);
}
// stats endpoints. `iata` is a single code (undefined = all regions); the /stats/* endpoints filter
// by one IATA only, unlike the comma-separated `iatas` used elsewhere.
export function getStatsOverview(iata?: string): Promise<StatsOverview> {
return request("/stats/overview", { iata });
}
export function getStatsObservations(iata?: string, since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iata, since });
}
export function getPayloadBreakdown(iata?: string, since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iata, since });
}
export function getTopNodes(iata?: string, limit = 10): Promise<TopNode[]> {
return request("/stats/top-nodes", { iata, limit });
}
export function getTopObservers(iata?: string, since?: number, limit = 10): Promise<TopObserver[]> {
return request("/stats/top-observers", { iata, since, limit });
}
export function getRadioPresets(iata?: string): Promise<RadioPreset[]> {
return request("/stats/radio-presets", { iata });
}
// renamed from getScopes to avoid colliding with the /scopes name list; this is the /stats/scopes
// aggregate (packet/observer/node counts), reported globally regardless of the active region.
export function getStatsScopes(): Promise<ScopeStats[]> {
return request("/stats/scopes");
}
export function getObserverTelemetry(
observerId: string,
range: string,
interval?: string,
afterId?: number,
): Promise<ObserverTelemetry> {
return request(`/observers/${observerId}/telemetry`, { range, interval, afterId });
}
export { ApiError };
+47
View File
@@ -0,0 +1,47 @@
import { useEffect, useRef } from "react";
import { echarts, type EChartsInstance, type EChartsOption } from "./echarts-setup";
interface EChartProps {
option: EChartsOption;
className?: string;
style?: React.CSSProperties;
// Map of ECharts event name -> handler (e.g. { click: (p) => ... }). Kept stable by the caller.
onEvents?: Record<string, (params: unknown) => void>;
}
// Thin React wrapper over the core ECharts API: init once, resize via ResizeObserver, dispose on
// unmount, and re-apply the (memoized) option with notMerge so theme/data swaps fully replace state.
// Hand-rolled on purpose — we avoid the echarts-for-react dependency.
export function EChart({ option, className, style, onEvents }: EChartProps) {
const elRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<EChartsInstance | null>(null);
useEffect(() => {
if (!elRef.current) return;
const chart = echarts.init(elRef.current, null, { renderer: "canvas" });
chartRef.current = chart;
const ro = new ResizeObserver(() => chart.resize());
ro.observe(elRef.current);
return () => {
ro.disconnect();
chart.dispose();
chartRef.current = null;
};
}, []);
useEffect(() => {
chartRef.current?.setOption(option, { notMerge: true });
}, [option]);
useEffect(() => {
const chart = chartRef.current;
if (!chart || !onEvents) return;
const entries = Object.entries(onEvents);
for (const [ev, handler] of entries) chart.on(ev, handler);
return () => {
for (const [ev, handler] of entries) chart.off(ev, handler);
};
}, [onEvents]);
return <div ref={elRef} className={className} style={{ width: "100%", height: "100%", ...style }} />;
}
+179
View File
@@ -0,0 +1,179 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useChartColors, type ChartColors } from "./chartTheme";
import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes } from "./useStats";
import { observationsAreaOption, leaderboardOption, donutOption } from "./chartOptions";
import { Card, ChartCard, StatCard } from "./cards";
import { useLiveOverview } from "./useLiveStats";
import { aggregatePresets, formatPreset } from "./transforms";
import type { WsManager } from "../../api/ws-manager";
import type { ObservationPoint, StatsRange } from "./types";
// 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);
}
function nodeTypeColor(typeName: string, c: ChartColors): string {
switch (typeName) {
case "companion": return c.primary;
case "repeater": return c.green;
case "room_server": return c.secondary;
case "sensor": return c.warn;
default: return c.primaryDim;
}
}
interface MeshTabProps {
range: StatsRange;
onSelectObserver: (observerId: string) => void;
wsManager: WsManager;
}
export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
const colors = useChartColors();
useLiveOverview(wsManager);
const overview = useStatsOverview();
const observations = useStatsObservations(range);
const payload = usePayloadBreakdown(range);
const topNodes = useTopNodes(10);
const topObservers = useTopObservers(range, 8);
const radioPresets = useRadioPresets();
const scopes = useScopes();
const obs = useMemo(() => aggregateByHour(observations.data ?? []), [observations.data]);
const obsOption = useMemo(() => observationsAreaOption(obs, colors), [obs, colors]);
const nodeRows = useMemo(
() =>
(topNodes.data ?? []).map((n) => ({
name: n.nodeName ?? n.nodeId.slice(0, 8),
value: n.observationCount,
color: nodeTypeColor(n.nodeTypeName, colors),
})),
[topNodes.data, colors],
);
const nodesOption = useMemo(() => leaderboardOption(nodeRows, colors), [nodeRows, colors]);
const payloadItems = useMemo(
() => (payload.data ?? []).map((p) => ({ name: p.payloadTypeName.toLowerCase(), value: p.count })),
[payload.data],
);
const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]);
const payloadOption = useMemo(
() => donutOption(payloadItems, colors, formatCount(payloadTotal), "OBS"),
[payloadItems, payloadTotal, colors],
);
const observerRows = useMemo(
() => (topObservers.data ?? []).map((o) => ({ name: o.displayName ?? o.observerId.slice(0, 8), value: o.observationCount, color: colors.secondary })),
[topObservers.data, colors],
);
const observersOption = useMemo(() => leaderboardOption(observerRows, colors), [observerRows, colors]);
const observerIds = useMemo(() => (topObservers.data ?? []).map((o) => o.observerId), [topObservers.data]);
const observerEvents = useMemo(
() => ({
click: (params: unknown) => {
const idx = (params as { dataIndex?: number }).dataIndex;
if (idx != null && observerIds[idx]) onSelectObserver(observerIds[idx]);
},
}),
[observerIds, onSelectObserver],
);
const nodeTypeData = useMemo(() => {
const counts = new Map<string, number>();
for (const n of topNodes.data ?? []) counts.set(n.nodeTypeName, (counts.get(n.nodeTypeName) ?? 0) + 1);
return [...counts.entries()].map(([name, value]) => ({ name, value, color: nodeTypeColor(name, colors) }));
}, [topNodes.data, colors]);
const nodeTypeTotal = useMemo(() => nodeTypeData.reduce((a, d) => a + d.value, 0), [nodeTypeData]);
const nodeTypeOption = useMemo(
() => donutOption(nodeTypeData, colors, String(nodeTypeTotal), "NODES"),
[nodeTypeData, nodeTypeTotal, colors],
);
const presetRows = useMemo(
() => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), value: r.value, color: colors.primary })),
[radioPresets.data, colors],
);
const presetsOption = useMemo(() => leaderboardOption(presetRows, colors, 150), [presetRows, colors]);
const scopeRows = useMemo(
() => [...(scopes.data ?? [])].sort((a, b) => b.packetCount - a.packetCount),
[scopes.data],
);
const obsSpark = useMemo(() => obs.slice(-24).map((p) => p.observationCount), [obs]);
const observerSpark = useMemo(() => obs.slice(-24).map((p) => p.activeObservers), [obs]);
const ov = overview.data;
const kpiLoading = overview.isLoading;
return (
<div className="mx-auto flex max-w-[1100px] flex-col gap-3.5 px-4 py-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label="Total packets" sublabel="24h" accent="var(--color-primary)" value={kpiLoading ? "—" : formatCount(ov?.totalPackets)} />
<StatCard label="Observations" sublabel="24h" accent="var(--color-green)" value={kpiLoading ? "—" : formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label="Active observers" sublabel="24h" accent="var(--color-secondary)" value={kpiLoading ? "—" : (ov?.activeObservers ?? "—")} spark={observerSpark} />
<StatCard label="Active IATAs" sublabel="24h" accent="var(--color-warn)" value={kpiLoading ? "—" : (ov?.activeIatas ?? "—")} />
</div>
<ChartCard
title={<>Observations · {range}</>}
height={200}
option={obsOption}
isLoading={observations.isLoading}
isError={observations.isError}
isEmpty={obs.length === 0}
/>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Top nodes" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<ChartCard title={<>Payload types · {range}</>} height={208} option={payloadOption} isLoading={payload.isLoading} isError={payload.isError} isEmpty={payloadItems.length === 0} />
<ChartCard title={<>Top observers · {range}</>} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
<ChartCard title="Node types · top" height={208} option={nodeTypeOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeTypeData.length === 0} />
<ChartCard title="Radio presets" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
<Card title={<>Scopes · all regions</>}>
{scopes.isError ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Failed to load</div>
) : scopes.isLoading ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Loading</div>
) : scopeRows.length === 0 ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">No data</div>
) : (
<table className="w-full font-mono text-[11px]">
<thead>
<tr className="text-text-muted">
<th className="pb-1.5 text-left font-semibold uppercase tracking-wider">Scope</th>
<th className="pb-1.5 text-right font-semibold uppercase tracking-wider">Packets</th>
<th className="pb-1.5 text-right font-semibold uppercase tracking-wider">Observers</th>
<th className="pb-1.5 text-right font-semibold uppercase tracking-wider">Nodes</th>
</tr>
</thead>
<tbody>
{scopeRows.map((s) => (
<tr key={s.name} className="border-t border-border-subtle">
<td className="py-1 text-left text-text-normal">{s.name}</td>
<td className={`py-1 text-right tabular-nums ${s.packetCount === 0 ? "text-text-dim" : "text-text-bright"}`}>{formatCount(s.packetCount)}</td>
<td className={`py-1 text-right tabular-nums ${s.observerCount === 0 ? "text-text-dim" : "text-text-normal"}`}>{formatCount(s.observerCount)}</td>
<td className={`py-1 text-right tabular-nums ${s.nodeCount === 0 ? "text-text-dim" : "text-text-normal"}`}>{formatCount(s.nodeCount)}</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
</div>
);
}
+181
View File
@@ -0,0 +1,181 @@
import { useEffect, useMemo } from "react";
import { Badge } from "../../components/Badge";
import { EmptyState } from "../../components/EmptyState";
import { formatBattery, formatCount, formatUptime } from "../../lib/formatters";
import { useChartColors } from "./chartTheme";
import { useTopObservers } from "./useStats";
import { useObserver, useObserverTelemetry } from "./useTelemetry";
import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption } from "./chartOptions";
import { Card, ChartCard } from "./cards";
import { hasTelemetry } from "./transforms";
import { useLiveObserver } from "./useLiveStats";
import type { WsManager } from "../../api/ws-manager";
import type { Observer } from "../observers/types";
import type { StatsRange } from "./types";
function ObserverList({
range,
selectedId,
onSelect,
}: {
range: StatsRange;
selectedId: string | null;
onSelect: (id: string) => void;
}) {
const { data, isLoading } = useTopObservers(range, 15);
const max = useMemo(() => Math.max(1, ...(data ?? []).map((o) => o.observationCount)), [data]);
return (
<Card title="Observers" className="w-full">
<div className="flex flex-col gap-0.5">
{isLoading && <div className="py-6 text-center font-mono text-[11px] text-text-dim">Loading</div>}
{!isLoading && (data ?? []).length === 0 && (
<div className="py-6 text-center font-mono text-[11px] text-text-dim">No observers</div>
)}
{(data ?? []).map((o) => {
const active = o.observerId === selectedId;
const name = o.displayName ?? o.observerId.slice(0, 8);
return (
<button
key={o.observerId}
type="button"
onClick={() => onSelect(o.observerId)}
className={`relative overflow-hidden rounded border-l-2 px-2.5 py-1.5 text-left transition-colors cursor-pointer ${
active ? "border-primary bg-primary/10" : "border-transparent hover:bg-white/3"
}`}
>
<div
className="absolute inset-y-0 left-0 bg-secondary/10"
style={{ width: `${(o.observationCount / max) * 100}%` }}
aria-hidden
/>
<div className="relative flex items-center justify-between gap-2">
<span className={`truncate font-mono text-[12px] ${active ? "text-text-bright" : "text-text-normal"}`}>{name}</span>
<span className="shrink-0 font-mono text-[11px] tabular-nums text-text-muted">{formatCount(o.observationCount)}</span>
</div>
</button>
);
})}
</div>
</Card>
);
}
function ObserverHeader({ observer }: { observer: Observer }) {
const radio = [
observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`,
observer.radioSf && `SF${observer.radioSf}`,
observer.radioBwKhz && `${observer.radioBwKhz} kHz`,
observer.radioCr && `CR 4/${observer.radioCr}`,
].filter(Boolean) as string[];
return (
<Card
title={
<span className="flex items-center gap-2">
<span className="text-text-bright normal-case">{observer.displayName ?? observer.id.slice(0, 8)}</span>
<Badge variant={observer.status === "online" ? "live" : "offline"}>{observer.status}</Badge>
{observer.observerType && <Badge variant="default">{observer.observerType}</Badge>}
</span>
}
right={
<span className="rounded-sm bg-primary/6 px-1.5 py-px font-mono text-[12px] font-semibold text-primary">{observer.iata}</span>
}
>
<div className="flex flex-wrap items-center gap-x-6 gap-y-1.5 font-mono text-[12px]">
<Metric label="Battery" value={observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"} />
<Metric label="Uptime" value={observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"} />
<Metric label="Observations" value={observer.observationCount.toLocaleString()} />
{radio.length > 0 && <Metric label="Radio" value={radio.join(" · ")} />}
</div>
</Card>
);
}
function Metric({ label, value }: { label: string; value: string }) {
return (
<span>
<span className="text-text-dim">{label} </span>
<span className="text-text-normal">{value}</span>
</span>
);
}
interface ObserverTabProps {
range: StatsRange;
selectedObserverId: string | null;
onSelectObserver: (observerId: string) => void;
wsManager: WsManager;
}
export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) {
const colors = useChartColors();
useLiveObserver(wsManager, selectedObserverId, range);
const topObservers = useTopObservers(range, 15);
const observer = useObserver(selectedObserverId);
const telemetry = useObserverTelemetry(selectedObserverId, range);
// default to the busiest observer once the list loads and nothing is selected
useEffect(() => {
if (selectedObserverId) return;
const first = topObservers.data?.[0];
if (first) onSelectObserver(first.observerId);
}, [selectedObserverId, topObservers.data, onSelectObserver]);
const points = telemetry.data?.points ?? [];
const airtime = useMemo(() => airtimeOption(points, colors), [points, colors]);
const battery = useMemo(() => batteryOption(points, colors), [points, colors]);
const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]);
const queue = useMemo(() => queueOption(points, colors), [points, colors]);
const recvErrors = useMemo(() => receiveErrorsOption(points, colors), [points, colors]);
// Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather
// than five flat-zero charts. When some telemetry exists, gate each chart on its own metric.
const ready = !telemetry.isLoading && !telemetry.isError;
const noTelemetry = ready && !hasTelemetry(points);
// a chart is empty when none of its metric(s) have a non-null value across the window
const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) =>
ready && !points.some((p) => accessors.some((a) => a(p) != null));
return (
<div className="mx-auto flex max-w-[1100px] flex-col gap-3.5 px-4 py-4 lg:flex-row">
<div className="w-full shrink-0 lg:w-[260px]">
<ObserverList range={range} selectedId={selectedObserverId} onSelect={onSelectObserver} />
</div>
<div className="flex min-w-0 flex-1 flex-col gap-3.5">
{!selectedObserverId ? (
<Card title="Telemetry">
<EmptyState title="Select an observer" subtitle="Pick an observer to view its telemetry" />
</Card>
) : (
<>
{observer.data && <ObserverHeader observer={observer.data} />}
{noTelemetry ? (
<Card title="Telemetry">
<EmptyState title="No telemetry reported" subtitle="This observer publishes status but no device telemetry" />
</Card>
) : (
<>
<ChartCard
title={<>Airtime TX / RX · {range}</>}
height={180}
option={airtime}
isLoading={telemetry.isLoading}
isError={telemetry.isError}
isEmpty={missing((p) => p.airtimeTxPct, (p) => p.airtimeRxPct)}
/>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Battery" height={168} option={battery} isLoading={telemetry.isLoading} isError={telemetry.isError} isEmpty={missing((p) => p.batteryMv)} />
<ChartCard title="Noise floor" height={168} option={noise} isLoading={telemetry.isLoading} isError={telemetry.isError} isEmpty={missing((p) => p.noiseFloorDb)} />
<ChartCard title="Queue length" height={168} option={queue} isLoading={telemetry.isLoading} isError={telemetry.isError} isEmpty={missing((p) => p.queueLength)} />
<ChartCard title="Receive errors" height={168} option={recvErrors} isLoading={telemetry.isLoading} isError={telemetry.isError} isEmpty={missing((p) => p.receiveErrors)} />
</div>
</>
)}
</>
)}
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
import type { ReactNode } from "react";
export interface SegmentedOption {
value: string;
label: string;
icon?: ReactNode;
}
interface SegmentedProps {
options: SegmentedOption[];
value: string;
onChange: (value: string) => void;
ariaLabel: string;
size?: "sm" | "md";
className?: string;
}
// A contained "pill group" segmented control bound to the palette: the active pill gets a primary
// tint + inset ring, inactive pills are muted. Used for the Stats sub-tabs (md, with icons) and the
// time-range selector (sm). Active state is conveyed with aria-pressed, not color alone.
export function Segmented({ options, value, onChange, ariaLabel, size = "sm", className }: SegmentedProps) {
const pad = size === "md" ? "px-3 py-1.5 text-xs" : "px-2.5 py-1 text-[11px]";
return (
<div
role="group"
aria-label={ariaLabel}
className={`inline-flex items-center gap-0.5 rounded-md border border-border bg-bg-raised p-0.5 ${className ?? ""}`}
>
{options.map((o) => {
const active = o.value === value;
return (
<button
key={o.value}
type="button"
aria-pressed={active}
onClick={() => onChange(o.value)}
className={`flex items-center gap-1.5 rounded font-mono font-semibold tracking-wide transition-colors cursor-pointer focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary ${pad} ${
active
? "bg-primary/15 text-text-bright ring-1 ring-inset ring-primary/30"
: "text-text-muted hover:text-text-normal"
}`}
>
{o.icon}
{o.label}
</button>
);
})}
</div>
);
}
+59 -3
View File
@@ -1,5 +1,61 @@
import { EmptyState } from "../../components/EmptyState";
import { useCallback } from "react";
import { useSearchParams } from "react-router-dom";
import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
import { ObserverTab } from "./ObserverTab";
import type { StatsRange, StatsTab } from "./types";
export function StatsOverview() {
return <EmptyState title="Stats" subtitle="Coming soon" />;
const TABS: StatsTab[] = ["mesh", "observer"];
const RANGES: StatsRange[] = ["24h", "7d", "30d"];
const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh");
const asRange = (v: string | null): StatsRange => (RANGES.includes(v as StatsRange) ? (v as StatsRange) : "7d");
interface StatsOverviewProps {
wsManager: WsManager;
}
// Stats page shell: a sub-header bar (Mesh / Observer pills + range + live dot) over the active
// sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the
// view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant.
export function StatsOverview({ wsManager }: StatsOverviewProps) {
const [params, setParams] = useSearchParams();
const tab = asTab(params.get("statsTab"));
const range = asRange(params.get("range"));
const observerId = params.get("observerId");
const patch = useCallback(
(updates: Record<string, string | null>) => {
setParams(
(prev) => {
const next = new URLSearchParams(prev);
for (const [k, v] of Object.entries(updates)) {
if (v == null) next.delete(k);
else next.set(k, v);
}
return next;
},
{ replace: true },
);
},
[setParams],
);
const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]);
const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]);
const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]);
return (
<div className="flex min-h-0 flex-1 flex-col">
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} wsManager={wsManager} />
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" ? (
<MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
) : (
<ObserverTab range={range} selectedObserverId={observerId} onSelectObserver={handleSelectObserver} wsManager={wsManager} />
)}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import type { WsManager } from "../../api/ws-manager";
import { useWsStatus } from "../../hooks/useWsStatus";
import { Segmented } from "./Segmented";
import type { StatsRange, StatsTab } from "./types";
function MeshIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<circle cx="3" cy="3" r="1.6" />
<circle cx="11" cy="4" r="1.6" />
<circle cx="7" cy="11" r="1.6" />
<path d="M4.3 3.6 9.7 4.4M3.6 4.4 6.4 9.6M10.4 5.4 7.7 9.7" strokeOpacity="0.7" />
</svg>
);
}
function ObserverIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<circle cx="7" cy="9.5" r="1.4" />
<path d="M7 8V4M4.5 6.5a3.5 3.5 0 0 1 5 0M2.7 4.7a6 6 0 0 1 8.6 0" strokeOpacity="0.85" />
</svg>
);
}
const TAB_OPTIONS = [
{ value: "mesh", label: "Mesh", icon: <MeshIcon /> },
{ value: "observer", label: "Observer", icon: <ObserverIcon /> },
];
const RANGE_OPTIONS = [
{ value: "24h", label: "24h" },
{ value: "7d", label: "7d" },
{ value: "30d", label: "30d" },
];
interface Props {
tab: StatsTab;
onTabChange: (tab: StatsTab) => void;
range: StatsRange;
onRangeChange: (range: StatsRange) => void;
wsManager: WsManager;
}
export function StatsSubHeader({ tab, onTabChange, range, onRangeChange, wsManager }: Props) {
const { status } = useWsStatus(wsManager);
const live = status === "connected";
const connecting = status === "connecting";
const dotColor = live ? "bg-green" : connecting ? "bg-warn" : "bg-text-dim";
const label = live ? "LIVE" : connecting ? "LIVE" : "OFFLINE";
const labelColor = live ? "text-green" : connecting ? "text-warn" : "text-text-dim";
return (
<div className="flex shrink-0 items-center justify-between border-b border-border bg-bg-surface px-4 py-2.5">
<Segmented
options={TAB_OPTIONS}
value={tab}
onChange={(v) => onTabChange(v as StatsTab)}
ariaLabel="Stats section"
size="md"
/>
<div className="flex items-center gap-3">
<Segmented
options={RANGE_OPTIONS}
value={range}
onChange={(v) => onRangeChange(v as StatsRange)}
ariaLabel="Time range"
/>
<div className={`flex items-center gap-1.5 font-mono text-[11px] font-semibold ${labelColor}`}>
<span className={`inline-block h-[7px] w-[7px] rounded-full ${dotColor} ${live ? "shadow-[0_0_0_3px_color-mix(in_srgb,var(--color-green)_18%,transparent)]" : ""}`} />
{label}
</div>
</div>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import type { ReactNode } from "react";
import { EChart } from "./EChart";
import type { EChartsOption } from "./echarts-setup";
// Titled surface card matching the app's panel language.
export function Card({
title,
right,
children,
className,
}: {
title: ReactNode;
right?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-lg border border-border bg-bg-surface p-3.5 ${className ?? ""}`}>
<div className="mb-2.5 flex items-center justify-between gap-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{title}</div>
{right}
</div>
{children}
</div>
);
}
function Sparkline({ values, color }: { values: number[]; color: string }) {
if (values.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 range = max - min || 1;
const pts = values
.map((v, i) => `${(i / (values.length - 1)) * w},${h - 1 - ((v - min) / range) * (h - 2)}`)
.join(" ");
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} />
</svg>
);
}
// KPI tile: label, big mono value, optional sparkline + sub-label.
export function StatCard({
label,
value,
accent,
spark,
sublabel,
}: {
label: string;
value: ReactNode;
accent: string; // CSS color for the sparkline, e.g. "var(--color-primary)"
spark?: number[];
sublabel?: ReactNode;
}) {
return (
<div className="rounded-lg border border-border bg-bg-surface px-3.5 py-3">
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] font-semibold uppercase tracking-wider text-text-muted">{label}</span>
{sublabel && <span className="font-mono text-[9px] text-text-dim">{sublabel}</span>}
</div>
<div className="mt-0.5 font-mono text-2xl font-bold tabular-nums text-text-bright">{value}</div>
{spark ? <Sparkline values={spark} color={accent} /> : <div className="mt-1.5 h-[20px]" />}
</div>
);
}
function Centered({ children }: { children: ReactNode }) {
return (
<div className="flex h-full items-center justify-center font-mono text-[11px] text-text-dim">{children}</div>
);
}
// Card whose body is a fixed-height ECharts chart, with loading/empty/error states.
export function ChartCard({
title,
right,
height = 200,
option,
isLoading,
isEmpty,
isError,
onEvents,
className,
}: {
title: ReactNode;
right?: ReactNode;
height?: number;
option: EChartsOption;
isLoading?: boolean;
isEmpty?: boolean;
isError?: boolean;
onEvents?: Record<string, (params: unknown) => void>;
className?: string;
}) {
return (
<Card title={title} right={right} className={className}>
<div style={{ height }}>
{isError ? (
<Centered>Failed to load</Centered>
) : isLoading ? (
<Centered>Loading</Centered>
) : isEmpty ? (
<Centered>No data</Centered>
) : (
<EChart option={option} onEvents={onEvents} />
)}
</div>
</Card>
);
}
+252
View File
@@ -0,0 +1,252 @@
import type { EChartsOption } from "./echarts-setup";
import { type ChartColors, tooltipStyle, withAlpha } from "./chartTheme";
import type { TelemetryPoint } from "./types";
const MONO = "JetBrains Mono, monospace";
function timeAxis(c: ChartColors) {
return {
type: "time" as const,
boundaryGap: false,
axisLine: { lineStyle: { color: c.border } },
axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, hideOverlap: true },
splitLine: { show: false },
};
}
function valueAxis(c: ChartColors, extra: Record<string, unknown> = {}) {
return {
type: "value" as const,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10 },
splitLine: { lineStyle: { color: c.border, opacity: 0.4 } },
...extra,
};
}
// ---- Mesh ----
export function observationsAreaOption(
points: { hour: number; observationCount: number; uniquePackets: number }[],
c: ChartColors,
): EChartsOption {
const obs = points.map((p) => [p.hour, p.observationCount]);
const uniq = points.map((p) => [p.hour, p.uniquePackets]);
return {
animation: false,
backgroundColor: "transparent",
grid: { left: 48, right: 14, top: 12, bottom: 24 },
tooltip: { trigger: "axis", ...tooltipStyle(c), axisPointer: { type: "line", lineStyle: { color: c.primary } } },
legend: {
data: ["Observations", "Unique packets"],
right: 8,
top: 0,
itemWidth: 10,
itemHeight: 10,
textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 },
inactiveColor: c.textDim,
},
xAxis: timeAxis(c),
yAxis: valueAxis(c),
series: [
{
name: "Observations",
type: "line",
smooth: true,
symbol: "none",
data: obs,
lineStyle: { color: c.primary, width: 2 },
itemStyle: { color: c.primary },
areaStyle: {
color: {
type: "linear",
x: 0, y: 0, x2: 0, y2: 1,
colorStops: [
{ offset: 0, color: withAlpha(c.primary, 0.42) },
{ offset: 1, color: withAlpha(c.primary, 0.01) },
],
},
},
},
{
name: "Unique packets",
type: "line",
smooth: true,
symbol: "none",
data: uniq,
lineStyle: { color: c.secondary, width: 1.3, type: "dashed" },
itemStyle: { color: c.secondary },
},
],
};
}
export function leaderboardOption(
rows: { name: string; value: number; color: string }[],
c: ChartColors,
gridLeft = 116, // widen for longer category labels (e.g. radio presets)
): EChartsOption {
return {
animation: false,
backgroundColor: "transparent",
grid: { left: gridLeft, right: 56, top: 6, bottom: 6 },
tooltip: { trigger: "item", ...tooltipStyle(c) },
xAxis: { type: "value", axisLabel: { show: false }, splitLine: { show: false }, axisLine: { show: false }, axisTick: { show: false } },
yAxis: {
type: "category",
inverse: true,
data: rows.map((r) => r.name),
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: c.textNormal, fontFamily: MONO, fontSize: 11 },
},
series: [
{
type: "bar",
barMaxWidth: 22,
barCategoryGap: "42%",
data: rows.map((r) => ({ value: r.value, itemStyle: { color: r.color, borderRadius: [0, 4, 4, 0] } })),
label: {
show: true,
position: "right",
color: c.textBright,
fontFamily: MONO,
fontSize: 11,
formatter: (p: { value: number }) => p.value.toLocaleString(),
},
},
],
};
}
export function donutOption(
items: { name: string; value: number; color?: string }[],
c: ChartColors,
centerValue: string,
centerLabel: string,
): EChartsOption {
return {
animation: false,
backgroundColor: "transparent",
tooltip: { trigger: "item", ...tooltipStyle(c), formatter: "{b}: {c} ({d}%)" },
legend: {
type: "scroll",
orient: "vertical",
right: 4,
top: "middle",
itemWidth: 9,
itemHeight: 9,
itemGap: 7,
// cap label width so long names (e.g. "anonymous_request") can't overrun into the donut
formatter: (name: string) => (name.length > 15 ? `${name.slice(0, 14)}` : name),
textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 },
pageIconColor: c.textMuted,
pageIconInactiveColor: c.textDim,
pageTextStyle: { color: c.textMuted, fontFamily: MONO, fontSize: 9 },
inactiveColor: c.textDim,
},
graphic: [
{ type: "text", left: "24%", top: "42%", style: { text: centerValue, fill: c.textBright, font: `700 21px ${MONO}` } },
{ type: "text", left: "24%", top: "56%", style: { text: centerLabel, fill: c.textMuted, font: `9px ${MONO}` } },
],
series: [
{
type: "pie",
radius: ["46%", "68%"],
center: ["27%", "50%"],
avoidLabelOverlap: false,
itemStyle: { borderColor: c.bgSurface, borderWidth: 2, borderRadius: 4 },
label: { show: false },
emphasis: { scaleSize: 5 },
data: items.map((it, i) => ({ name: it.name, value: it.value, itemStyle: { color: it.color ?? c.series[i % c.series.length] } })),
},
],
};
}
// ---- Observer telemetry ----
// `t` arrives in epoch ms (normalized in useObserverTelemetry).
// airtimeTx/RxPct are cumulative counters, so chart the per-report delta (airtime used per interval),
// clamped at 0 to ignore counter resets. Caveat: under bucketing (7d/30d) the backend AVGs these
// counters, so the delta is approximate — pending a backend MAXMIN fix (beacon-docs ticket).
function deltaSeries(points: TelemetryPoint[], key: "airtimeRxPct" | "airtimeTxPct") {
const out: [number, number | null][] = [];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1]![key];
const cur = points[i]![key];
const d = prev != null && cur != null ? Math.max(0, cur - prev) : null;
out.push([points[i]!.t, d]);
}
return out;
}
export function airtimeOption(points: TelemetryPoint[], c: ChartColors): EChartsOption {
return {
animation: false,
backgroundColor: "transparent",
grid: { left: 44, right: 14, top: 24, bottom: 22 },
legend: { data: ["RX", "TX"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } },
tooltip: { trigger: "axis", ...tooltipStyle(c) },
xAxis: timeAxis(c),
yAxis: valueAxis(c),
series: [
{ name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeRxPct"), lineStyle: { width: 1, color: c.green }, areaStyle: { color: withAlpha(c.green, 0.35) }, itemStyle: { color: c.green } },
{ name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: deltaSeries(points, "airtimeTxPct"), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } },
],
};
}
// Single-metric line chart (small multiple). `delta` charts the per-report increase of a cumulative
// counter; `area` adds a fill (use only for counters that sit near zero, not offset ranges like dBm/V).
function seriesData(points: TelemetryPoint[], accessor: (p: TelemetryPoint) => number | null, delta: boolean) {
if (!delta) return points.map((p) => [p.t, accessor(p)]);
const out: [number, number | null][] = [];
for (let i = 1; i < points.length; i++) {
const a = accessor(points[i - 1]!);
const b = accessor(points[i]!);
out.push([points[i]!.t, a != null && b != null ? Math.max(0, b - a) : null]);
}
return out;
}
function metricLineOption(
points: TelemetryPoint[],
c: ChartColors,
o: { name: string; color: string; accessor: (p: TelemetryPoint) => number | null; delta?: boolean; area?: boolean },
): EChartsOption {
return {
animation: false,
backgroundColor: "transparent",
grid: { left: 50, right: 14, top: 14, bottom: 22 },
tooltip: { trigger: "axis", ...tooltipStyle(c) },
xAxis: timeAxis(c),
yAxis: valueAxis(c, { scale: true }),
series: [
{
name: o.name,
type: "line",
smooth: true,
symbol: "none",
connectNulls: true,
data: seriesData(points, o.accessor, o.delta ?? false),
lineStyle: { color: o.color, width: 1.8 },
itemStyle: { color: o.color },
...(o.area ? { areaStyle: { color: withAlpha(o.color, 0.16) } } : {}),
},
],
};
}
export const batteryOption = (p: TelemetryPoint[], c: ChartColors) =>
metricLineOption(p, c, { name: "Battery V", color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) });
export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) =>
metricLineOption(p, c, { name: "Noise dBm", color: c.warn, accessor: (x) => x.noiseFloorDb });
export const queueOption = (p: TelemetryPoint[], c: ChartColors) =>
metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true });
export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors) =>
metricLineOption(p, c, { name: "Recv errors / report", color: c.danger, accessor: (x) => x.receiveErrors, delta: true, area: true });
+110
View File
@@ -0,0 +1,110 @@
import { useMemo } from "react";
import { useTheme } from "../../hooks/useTheme";
// ECharts paints to a canvas and can't inherit our CSS variables, so we read the active palette's
// resolved `--color-*` tokens (defined in index.css `@theme`, which always resolve — palette value or
// fallback) and hand them to the option builders. `useChartColors()` re-reads whenever the theme id
// changes, so every chart recolors on theme switch.
export interface ChartColors {
primary: string;
primaryDim: string;
secondary: string;
green: string;
warn: string;
danger: string;
textBright: string;
textNormal: string;
textMuted: string;
textDim: string;
bgBase: string;
bgSurface: string;
bgRaised: string;
border: string;
borderSubtle: string;
// categorical palette for donuts / multi-series, derived from the theme so it stays on-brand.
series: string[];
}
function readVar(name: string): string {
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
}
type RGB = [number, number, number];
function parseColor(c: string): RGB {
const s = c.trim();
if (s.startsWith("#")) {
let h = s.slice(1);
if (h.length === 3) h = h.split("").map((ch) => ch + ch).join("");
const n = parseInt(h, 16);
return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}
const m = s.match(/rgba?\(([^)]+)\)/i);
if (m && m[1]) {
const parts = m[1].split(",").map((p) => parseFloat(p) || 0);
return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
}
return [128, 128, 128];
}
export function withAlpha(color: string, a: number): string {
const [r, g, b] = parseColor(color);
return `rgba(${r}, ${g}, ${b}, ${a})`;
}
function blend(a: string, b: string, t = 0.5): string {
const [r1, g1, b1] = parseColor(a);
const [r2, g2, b2] = parseColor(b);
const mix = (x: number, y: number) => Math.round(x + (y - x) * t);
return `rgb(${mix(r1, r2)}, ${mix(g1, g2)}, ${mix(b1, b2)})`;
}
export function readChartColors(): ChartColors {
const c = {
primary: readVar("--color-primary") || "#3B82F6",
primaryDim: readVar("--color-primary-dim") || "#1D4ED8",
secondary: readVar("--color-secondary") || "#A78BFA",
green: readVar("--color-green") || "#22C55E",
warn: readVar("--color-warn") || "#EAB308",
danger: readVar("--color-danger") || "#EF4444",
textBright: readVar("--color-text-bright") || "#FAFAFA",
textNormal: readVar("--color-text-normal") || "#A1A1AA",
textMuted: readVar("--color-text-muted") || "#73737B",
textDim: readVar("--color-text-dim") || "#5F5F65",
bgBase: readVar("--color-bg-base") || "#09090B",
bgSurface: readVar("--color-bg-surface") || "#111114",
bgRaised: readVar("--color-bg-raised") || "#1A1A1F",
border: readVar("--color-border") || "#27272A",
borderSubtle: readVar("--color-border-subtle") || "#1E1E22",
};
// 8 categorical colors blended from the palette so any theme stays cohesive.
const series = [
c.primary,
c.green,
c.secondary,
c.warn,
c.danger,
c.primaryDim,
blend(c.primary, c.secondary),
blend(c.green, c.warn),
];
return { ...c, series };
}
export function useChartColors(): ChartColors {
const { themeId } = useTheme();
// themeId changes after the palette CSS vars are applied, so re-reading here is correct.
return useMemo(() => readChartColors(), [themeId]);
}
// A reusable ECharts tooltip style block bound to the active palette.
export function tooltipStyle(c: ChartColors) {
return {
backgroundColor: c.bgRaised,
borderColor: c.border,
borderWidth: 1,
padding: [7, 11] as [number, number],
textStyle: { color: c.textBright, fontFamily: "JetBrains Mono, monospace", fontSize: 11 },
};
}
+33
View File
@@ -0,0 +1,33 @@
// Tree-shaken ECharts registration. Import `echarts` from here (never the `echarts` barrel) so the
// bundle only pulls the chart types + components the Stats page actually uses. We deliberately avoid
// the `echarts-for-react` wrapper (it was hit by a supply-chain attack 2026-05-19); EChart.tsx wraps
// the core API directly instead.
import * as echarts from "echarts/core";
import { LineChart, BarChart, PieChart, GaugeChart } from "echarts/charts";
import {
GridComponent,
TooltipComponent,
LegendComponent,
GraphicComponent,
DataZoomComponent,
MarkLineComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
echarts.use([
LineChart,
BarChart,
PieChart,
GaugeChart,
GridComponent,
TooltipComponent,
LegendComponent,
GraphicComponent,
DataZoomComponent,
MarkLineComponent,
CanvasRenderer,
]);
export { echarts };
export type EChartsInstance = ReturnType<typeof echarts.init>;
export type EChartsOption = Parameters<EChartsInstance["setOption"]>[0];
+45
View File
@@ -0,0 +1,45 @@
import type { RadioPreset, TelemetryPoint } from "./types";
// Collapse radio presets (one row per preset+iata+sourceType) into one row per preset, summing
// counts, sorted by descending total. Junk presets (all-zero "0,0,0" from unconfigured radios) are
// dropped so they don't clutter the chart.
export function aggregatePresets(rows: RadioPreset[]): { preset: string; value: number }[] {
const byPreset = new Map<string, number>();
for (const r of rows) {
if (isJunkPreset(r.preset)) continue;
byPreset.set(r.preset, (byPreset.get(r.preset) ?? 0) + r.count);
}
return [...byPreset.entries()]
.map(([preset, value]) => ({ preset, value }))
.sort((a, b) => b.value - a.value);
}
function isJunkPreset(preset: string): boolean {
return preset.split(",").every((n) => Number(n) === 0);
}
// "freqMhz,bwKhz,sf" -> "910.525 · 62.5k · SF7" (freq is MHz by convention); anything that isn't a
// freq,bw,sf triple is shown as-is.
export function formatPreset(preset: string): string {
const parts = preset.split(",");
if (parts.length !== 3 || parts.some((p) => p === "" || Number.isNaN(Number(p)))) return preset;
const [freq, bw, sf] = parts;
return `${freq} · ${bw}k · SF${sf}`;
}
// True if any point carries at least one meaningful (non-null, non-zero) metric. Bots / MQTT bridges
// report telemetry rows that are all zeros (no real radio hardware); those count as "no telemetry"
// so we show an empty state rather than a wall of flat-zero charts.
export function hasTelemetry(points: TelemetryPoint[]): boolean {
const live = (v: number | null) => v != null && v !== 0;
return points.some(
(p) =>
live(p.batteryMv) ||
live(p.airtimeTxPct) ||
live(p.airtimeRxPct) ||
live(p.noiseFloorDb) ||
live(p.uptimeSeconds) ||
live(p.queueLength) ||
live(p.receiveErrors),
);
}
+82
View File
@@ -0,0 +1,82 @@
// Response shapes for the /stats/* endpoints and observer telemetry. Verified against tower-server.
export interface StatsOverview {
totalPackets: number;
totalObservations: number;
activeObservers: number;
activeIatas: number;
windowHours: number;
}
export interface ObservationPoint {
hour: number; // epoch ms, start of the hourly bucket
iata: string;
observationCount: number;
uniquePackets: number;
activeObservers: number;
}
export interface PayloadBreakdownItem {
payloadType: number;
payloadTypeName: string;
count: number;
}
export interface TopNode {
nodeId: string;
nodeName: string | null;
nodeType: number;
nodeTypeName: string;
iata: string;
observationCount: number;
lastHeard: number; // epoch ms
}
export interface TopObserver {
observerId: string;
displayName: string | null;
observerType: string | null;
iata: string;
observationCount: number;
}
export interface RadioPreset {
preset: string; // "freqMhz,bwKhz,sf" e.g. "910.525,62.5,7"
iata: string;
sourceType: string; // "observer" or "node"
count: number;
}
export interface ScopeStats {
name: string; // normalized scope name e.g. "#bc"
packetCount: number;
observerCount: number;
nodeCount: number;
}
export interface TelemetryPoint {
t: number; // epoch ms (normalized in useObserverTelemetry — backend raw path emits seconds)
batteryMv: number | null;
airtimeTxPct: number | null;
airtimeRxPct: number | null;
noiseFloorDb: number | null;
uptimeSeconds: number | null;
queueLength: number | null;
receiveErrors: number | null;
}
export interface ObserverTelemetry {
range: string;
interval: string;
points: TelemetryPoint[];
}
// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "observer";
export type StatsRange = "24h" | "7d" | "30d";
export const RANGE_MS: Record<StatsRange, number> = {
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
+64
View File
@@ -0,0 +1,64 @@
import { useCallback, useEffect, useRef } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useRegion } from "../../hooks/useRegion";
import { useWsPacketHandler, 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.
export function useLiveOverview(wsManager: WsManager) {
const region = 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 };
qc.setQueryData<StatsOverview>(["stats-overview", region], (old) =>
old
? { ...old, totalPackets: old.totalPackets + packets, totalObservations: old.totalObservations + obs }
: old,
);
}, [qc, region]);
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);
useEffect(
() => () => {
if (raf.current != null) cancelAnimationFrame(raf.current);
},
[],
);
}
// 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.
export function useLiveObserver(wsManager: WsManager, observerId: string | null, range: StatsRange) {
const qc = useQueryClient();
const onStatus = useCallback(
(data: WsObserverStatus["data"]) => {
if (!observerId || data.observerId !== observerId) return;
qc.invalidateQueries({ queryKey: ["observer", observerId] });
qc.invalidateQueries({ queryKey: ["observer-telemetry", observerId, range] });
},
[qc, observerId, range],
);
useWsObserverStatusHandler(wsManager, onStatus);
}
+94
View File
@@ -0,0 +1,94 @@
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { useRegion } from "../../hooks/useRegion";
import {
getStatsOverview,
getStatsObservations,
getPayloadBreakdown,
getTopNodes,
getTopObservers,
getRadioPresets,
getStatsScopes,
} from "../../api/client";
import { RANGE_MS, type StatsRange } from "./types";
// Shared query options: cache for 30s, keep previous data so region/range switches don't flash.
const common = {
staleTime: 30_000,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
} as const;
// `since` is computed inside queryFn so refetches use a fresh window without churning the query key.
const sinceFor = (range: StatsRange) => Date.now() - RANGE_MS[range];
// The /stats/* endpoints filter by a single IATA. Map the region selection to one: a single selected
// IATA filters; "all regions" or a multi-IATA region passes nothing (the endpoints then span all).
function useStatsIata(): { iata: string | undefined; regionKey: string } {
const { iatas, regionKey } = useRegion();
return { iata: iatas?.length === 1 ? iatas[0] : undefined, regionKey };
}
export function useStatsOverview() {
const { iata, regionKey } = useStatsIata();
return useQuery({
queryKey: ["stats-overview", regionKey],
queryFn: () => getStatsOverview(iata),
...common,
// self-correct the WS-accumulated live counters against the server
refetchInterval: 60_000,
});
}
export function useStatsObservations(range: StatsRange) {
const { iata, regionKey } = useStatsIata();
return useQuery({
queryKey: ["stats-observations", regionKey, range],
queryFn: () => getStatsObservations(iata, sinceFor(range)),
...common,
});
}
export function usePayloadBreakdown(range: StatsRange) {
const { iata, regionKey } = useStatsIata();
return useQuery({
queryKey: ["stats-payload", regionKey, range],
queryFn: () => getPayloadBreakdown(iata, sinceFor(range)),
...common,
});
}
export function useTopNodes(limit = 10) {
const { iata, regionKey } = useStatsIata();
return useQuery({
queryKey: ["stats-top-nodes", regionKey, limit],
queryFn: () => getTopNodes(iata, limit),
...common,
});
}
export function useTopObservers(range: StatsRange, limit = 10) {
const { iata, regionKey } = useStatsIata();
return useQuery({
queryKey: ["stats-top-observers", regionKey, range, limit],
queryFn: () => getTopObservers(iata, sinceFor(range), limit),
...common,
});
}
export function useRadioPresets() {
const { iata, regionKey } = useStatsIata();
return useQuery({
queryKey: ["stats-radio-presets", regionKey],
queryFn: () => getRadioPresets(iata),
...common,
});
}
// scopes are reported globally by the backend (no region filter), so the key is region-independent
export function useScopes() {
return useQuery({
queryKey: ["stats-scopes"],
queryFn: getStatsScopes,
...common,
});
}
+47
View File
@@ -0,0 +1,47 @@
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { getObserver, getObserverTelemetry } from "../../api/client";
import type { ObserverTelemetry, StatsRange } from "./types";
// Go time.ParseDuration strings the telemetry endpoint expects, per selected range.
const RANGE_PARAM: Record<StatsRange, string> = {
"24h": "24h",
"7d": "168h",
"30d": "720h",
};
// Bucketing interval per range: raw 1h points at 24h, coarser buckets for the longer windows so the
// charts don't drown in points.
const INTERVAL_PARAM: Record<StatsRange, string> = {
"24h": "1h",
"7d": "6h",
"30d": "24h",
};
// The backend's raw (interval=1h) path emits `t` in epoch SECONDS while the bucketed path emits ms.
// Normalize everything to ms here so chart code is unit-agnostic. (Tracked: beacon-docs ticket.)
export function normalizeTelemetry(data: ObserverTelemetry, interval: string): ObserverTelemetry {
if (interval !== "1h") return data;
return { ...data, points: data.points.map((p) => ({ ...p, t: p.t * 1000 })) };
}
export function useObserver(observerId: string | null) {
return useQuery({
queryKey: ["observer", observerId],
queryFn: () => getObserver(observerId!),
enabled: !!observerId,
staleTime: 30_000,
refetchOnWindowFocus: false,
});
}
export function useObserverTelemetry(observerId: string | null, range: StatsRange) {
const interval = INTERVAL_PARAM[range];
return useQuery({
queryKey: ["observer-telemetry", observerId, range, interval],
queryFn: async () => normalizeTelemetry(await getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), interval),
enabled: !!observerId,
staleTime: 30_000,
placeholderData: keepPreviousData,
refetchOnWindowFocus: false,
});
}
+10
View File
@@ -56,6 +56,16 @@ export function formatBattery(volts: number): string {
return `${volts.toFixed(2)}V`;
}
// Compact large counts for KPI/stat displays: 932 -> "932", 14732 -> "14.7k", 8_900_000 -> "8.9M".
export function formatCount(n: number | null | undefined): string {
if (n == null) return "—";
const abs = Math.abs(n);
if (abs < 1000) return String(n);
if (abs < 1_000_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
if (abs < 1_000_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
return `${(n / 1_000_000_000).toFixed(1).replace(/\.0$/, "")}B`;
}
// /nodes sends lat/lng as integer microdegrees (45141660 = 45.141660); scale those to decimal.
// Values that are already decimal pass through untouched — the integer check tells them apart.
export function microToDeg(v: number): number {
@@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { hasTelemetry } from "../../../src/features/stats/transforms";
import type { TelemetryPoint } from "../../../src/features/stats/types";
const empty = (t: number): TelemetryPoint => ({ t, batteryMv: null, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null });
describe("hasTelemetry", () => {
it("is false for no points", () => {
expect(hasTelemetry([])).toBe(false);
});
it("is false when every metric on every point is null", () => {
expect(hasTelemetry([empty(1), empty(2)])).toBe(false);
});
it("is true when any metric on any point is a meaningful non-zero value", () => {
expect(hasTelemetry([empty(1), { ...empty(2), noiseFloorDb: -110 }])).toBe(true);
});
it("is false when every metric is zero (bots / MQTT bridges report all-zero rows)", () => {
const allZero: TelemetryPoint = { t: 1, batteryMv: 0, airtimeTxPct: 0, airtimeRxPct: 0, noiseFloorDb: 0, uptimeSeconds: 0, queueLength: 0, receiveErrors: 0 };
expect(hasTelemetry([allZero, allZero])).toBe(false);
});
});
@@ -0,0 +1,44 @@
import { describe, it, expect } from "vitest";
import { aggregatePresets, formatPreset } from "../../../src/features/stats/transforms";
import type { RadioPreset } from "../../../src/features/stats/types";
const row = (preset: string, sourceType: string, iata: string, count: number): RadioPreset => ({ preset, sourceType, iata, count });
describe("aggregatePresets", () => {
it("sums counts for the same preset across sourceType and iata", () => {
const rows = [
row("910.525,62.5,7", "observer", "YVR", 3),
row("910.525,62.5,7", "node", "YVR", 5),
row("910.525,62.5,7", "observer", "YYJ", 2),
row("869.525,250,11", "node", "YVR", 4),
];
const out = aggregatePresets(rows);
const byPreset = Object.fromEntries(out.map((r) => [r.preset, r.value]));
expect(byPreset["910.525,62.5,7"]).toBe(10);
expect(byPreset["869.525,250,11"]).toBe(4);
});
it("returns rows sorted by descending count", () => {
const rows = [row("910.5,62.5,7", "node", "YVR", 1), row("868,250,11", "node", "YVR", 9), row("915,125,9", "node", "YVR", 5)];
expect(aggregatePresets(rows).map((r) => r.preset)).toEqual(["868,250,11", "915,125,9", "910.5,62.5,7"]);
});
it("drops junk all-zero presets", () => {
const rows = [row("910.525,62.5,7", "node", "YVR", 6), row("0,0,0", "observer", "YVR", 1)];
expect(aggregatePresets(rows).map((r) => r.preset)).toEqual(["910.525,62.5,7"]);
});
it("handles an empty input", () => {
expect(aggregatePresets([])).toEqual([]);
});
});
describe("formatPreset", () => {
it("renders freq/bw/sf in a human-readable label", () => {
expect(formatPreset("910.525,62.5,7")).toBe("910.525 · 62.5k · SF7");
});
it("falls back to the raw string when it is not the expected triple", () => {
expect(formatPreset("weird")).toBe("weird");
});
});
@@ -0,0 +1,38 @@
import { describe, it, expect } from "vitest";
import { normalizeTelemetry } from "../../../src/features/stats/useTelemetry";
import type { ObserverTelemetry } from "../../../src/features/stats/types";
const SEC = 1_700_000_000; // a second-scale epoch
const MS = SEC * 1000;
describe("normalizeTelemetry", () => {
it("scales raw (1h) second-epoch points up to ms", () => {
const raw: ObserverTelemetry = {
range: "24h",
interval: "1h",
points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }],
};
expect(normalizeTelemetry(raw, "1h").points[0]!.t).toBe(MS);
});
it("leaves bucketed (6h/24h) ms-epoch points untouched", () => {
const bucketed: ObserverTelemetry = {
range: "7d",
interval: "6h",
points: [{ t: MS, batteryMv: 3700, airtimeTxPct: null, airtimeRxPct: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }],
};
expect(normalizeTelemetry(bucketed, "6h").points[0]!.t).toBe(MS);
});
it("does not mutate other fields", () => {
const raw: ObserverTelemetry = {
range: "24h",
interval: "1h",
points: [{ t: SEC, batteryMv: 3700, airtimeTxPct: 1.5, airtimeRxPct: 2.5, noiseFloorDb: -110, uptimeSeconds: 42, queueLength: 3, receiveErrors: 1 }],
};
const p = normalizeTelemetry(raw, "1h").points[0]!;
expect(p.batteryMv).toBe(3700);
expect(p.airtimeTxPct).toBe(1.5);
expect(p.receiveErrors).toBe(1);
});
});