mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-01 16:48:19 +00:00
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:
Generated
+328
-286
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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} />,
|
||||
};
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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 }} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 MAX−MIN 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 });
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user