Files
beacon-web/src/features/stats/StatsOverview.tsx
T
MrAlders0n be072bb42c 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
2026-06-09 22:02:27 -04:00

62 lines
2.5 KiB
TypeScript

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";
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>
);
}