Files
beacon-web/src/features/stats/MeshTab.tsx
T
MrAlders0n df9f4a07ad stats: node-types donut, multi-IATA filtering, preset split
- node-types: new /stats/node-types endpoint + useNodeTypes; donutOption
   replaces the "Coming soon" card with a live donut
 - stats endpoints take iatas[] instead of a single iata, so multi-IATA
   regions filter instead of falling back to all (drops useStatsIata)
 - radio presets keep the node/observer split via stacked presetBarsOption
 - drop client-side telemetry ms-normalization (backend now emits epoch ms
   on both paths); charts pick delta-vs-raw counters off the response interval
 - MeshTab: range-driven charts lead, all-time charts follow; KPI window
   from overview.windowHours instead of a hardcoded 24h
 - ci: docker-publish builds a :dev image on dev-branch pushes
2026-06-11 13:34:11 -04:00

192 lines
9.5 KiB
TypeScript

import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useChartColors, type ChartColors } from "./chartTheme";
import { useStatsOverview, useStatsObservations, usePayloadBreakdown, useTopNodes, useTopObservers, useRadioPresets, useScopes, useNodeTypes } from "./useStats";
import { observationsAreaOption, leaderboardOption, typeBarOption, donutOption, presetBarsOption } 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 nodeTypes = useNodeTypes();
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 }))
.sort((a, b) => b.value - a.value),
[payload.data],
);
const payloadTotal = useMemo(() => payloadItems.reduce((a, p) => a + p.value, 0), [payloadItems]);
const payloadOption = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, 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 typeRows = useMemo(
() =>
[...(nodeTypes.data ?? [])]
.sort((a, b) => b.count - a.count)
.map((t) => ({ name: t.nodeTypeName, value: t.count, color: nodeTypeColor(t.nodeTypeName, colors) })),
[nodeTypes.data, colors],
);
const typeTotal = useMemo(() => typeRows.reduce((a, t) => a + t.value, 0), [typeRows]);
const typesOption = useMemo(() => donutOption(typeRows, colors, formatCount(typeTotal), "NODES"), [typeRows, colors, typeTotal]);
const presetRows = useMemo(
() => aggregatePresets(radioPresets.data ?? []).slice(0, 8).map((r) => ({ name: formatPreset(r.preset), nodes: r.nodes, observers: r.observers })),
[radioPresets.data],
);
const presetsOption = useMemo(() => presetBarsOption(presetRows, colors), [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;
// top-row KPIs are the overview endpoint's fixed 24h snapshot; range only drives the charts below
const ovWindow = `${ov?.windowHours ?? 24}h`;
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={ovWindow} accent="var(--color-primary)" value={kpiLoading ? "—" : formatCount(ov?.totalPackets)} />
<StatCard label="Observations" sublabel={ovWindow} accent="var(--color-green)" value={kpiLoading ? "—" : formatCount(ov?.totalObservations)} spark={obsSpark} />
<StatCard label="Active observers" sublabel={ovWindow} accent="var(--color-secondary)" value={kpiLoading ? "—" : (ov?.activeObservers ?? "—")} spark={observerSpark} />
<StatCard label="Active IATAs" sublabel={ovWindow} 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">
{/* range-driven charts lead the grid; the all-time ones follow below */}
<ChartCard title={<>Top observers · {range}</>} height={208} option={observersOption} isLoading={topObservers.isLoading} isError={topObservers.isError} isEmpty={observerRows.length === 0} onEvents={observerEvents} />
<ChartCard
title={<>Payload types · {range}</>}
right={<span className="font-mono text-[10px] text-text-muted">{formatCount(payloadTotal)} obs</span>}
height={208}
option={payloadOption}
isLoading={payload.isLoading}
isError={payload.isError}
isEmpty={payloadItems.length === 0}
/>
{/* counts are all-time; the server's 7d filter only prunes the roster to recently-heard nodes */}
<ChartCard title="Top nodes · all time" height={208} option={nodesOption} isLoading={topNodes.isLoading} isError={topNodes.isError} isEmpty={nodeRows.length === 0} />
<ChartCard title="Node types · all time" height={208} option={typesOption} isLoading={nodeTypes.isLoading} isError={nodeTypes.isError} isEmpty={typeRows.length === 0} />
<ChartCard title="Radio presets · all time" height={208} option={presetsOption} isLoading={radioPresets.isLoading} isError={radioPresets.isError} isEmpty={presetRows.length === 0} />
<Card title={<>Scopes · all regions · all time</>}>
{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>
);
}