Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ function AppInner() {
/>
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<div className="relative flex flex-1 min-h-0">
<div key={activeTab} className="flex flex-1 min-h-0 fade-in">
<div key={activeTab} className="flex flex-1 min-h-0 min-w-0 fade-in">
<Suspense fallback={<EmptyState title={activeTab} subtitle="Loading…" />}>
{tabContent[activeTab]}
</Suspense>
Expand Down
10 changes: 5 additions & 5 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,8 +305,8 @@ export function getStatsOverview(iatas?: string[]): Promise<StatsOverview> {
return request("/stats/overview", { iatas: iatasParam(iatas) });
}

export function getStatsObservations(iatas?: string[], since?: number): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since });
export function getStatsObservations(iatas?: string[], since?: number, signal?: AbortSignal): Promise<ObservationPoint[]> {
return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal);
}

export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
Expand Down Expand Up @@ -344,9 +344,9 @@ export function getClockDrift(iatas?: string[], limit = 100): Promise<ClockDrift
}

// 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");
// aggregate (packet/observer/node counts), filtered by the selected IATAs.
export function getStatsScopes(iatas?: string[], signal?: AbortSignal): Promise<ScopeStats[]> {
return request("/stats/scopes", { iatas: iatasParam(iatas) }, signal);
}

export function getObserverTelemetry(
Expand Down
4 changes: 2 additions & 2 deletions src/features/stats/MeshTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,10 @@ export function MeshTab({ range, onSelectObserver, wsManager }: MeshTabProps) {
<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</>}>
<Card title={<>Scopes · selected region · retained data</>}>
{scopes.isError ? (
<div className="py-4 text-center font-mono text-[11px] text-text-dim">Failed to load</div>
) : scopes.isLoading ? (
) : scopes.isPending || scopes.isLoading || scopes.isPlaceholderData ? (
<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>
Expand Down
59 changes: 59 additions & 0 deletions src/features/stats/ScopesTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useMemo, useState } from "react";
import { useScopes } from "./useStats";
import { useChartColors } from "./chartTheme";
import { Card, ChartCard, StatCard } from "./cards";
import { scopeChartOption, scopeSummary } from "./scopes";
import { formatCount } from "../../lib/formatters";

export function ScopesTab() {
const query = useScopes();
const colors = useChartColors();
const [search, setSearch] = useState("");
const loading = query.isPending || query.isLoading || query.isPlaceholderData;
const unavailable = loading || query.isError;
const all = useMemo(() => unavailable ? [] : (query.data ?? []), [query.data, unavailable]);
const rows = useMemo(() => all.filter((row) => row.name.toLowerCase().includes(search.trim().toLowerCase())).sort((a, b) => b.packetCount - a.packetCount || a.name.localeCompare(b.name)), [all, search]);
const totals = useMemo(() => scopeSummary(rows), [rows]);
const packets = useMemo(() => scopeChartOption(rows, "packetCount", colors), [rows, colors]);
const observers = useMemo(() => scopeChartOption(rows, "observerCount", colors), [rows, colors]);
const nodes = useMemo(() => scopeChartOption(rows, "nodeCount", colors), [rows, colors]);
const value = (number: number) => unavailable ? "—" : formatCount(number);
const height = Math.max(180, Math.min(13, rows.length) * 28 + 16);

return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h2 className="text-lg font-semibold text-text-bright">Transport scopes</h2><p className="text-sm text-text-muted">Retained data and memberships for the selected region.</p></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">Refresh scopes</button>
</div>
<label className="flex max-w-sm flex-col gap-1 text-xs text-text-muted">Find a scope
<input type="search" value={search} onChange={(event) => setSearch(event.target.value)} placeholder="#bc, #east…" className="rounded border border-border bg-bg-raised px-3 py-2 text-base text-text-bright outline-none focus:border-primary sm:text-sm" />
</label>
{query.isError && <p role="alert" className="text-sm text-danger">Could not load scopes. Try refreshing.</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Scopes with data" value={value(totals.active)} accent={colors.secondary} />
<StatCard label="Scoped packets" value={value(totals.packets)} accent={colors.primary} />
<StatCard label="Observer memberships" value={value(totals.memberships)} accent={colors.green} />
<StatCard label="Default-scope nodes" value={value(totals.nodes)} accent={colors.warn} />
</div>
<p className="text-xs leading-relaxed text-text-muted">An observer can appear in more than one scope. Global observer counts include stored memberships; regional counts use retained scoped receptions. Nodes are counted in their default scope. These counts have no selected time window.</p>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Packets by scope" option={packets} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.packets} />
<ChartCard title="Observer memberships by scope" option={observers} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.memberships} />
<ChartCard title="Default-scope nodes" option={nodes} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.nodes} />
<Card title="Scope counts" right={<span className="text-[10px] text-text-muted">{unavailable ? "—" : `${rows.length} of ${all.length} scopes`}</span>}>
{unavailable ? <p className="py-6 text-sm text-text-muted">{query.isError ? "Data unavailable" : "Loading scopes…"}</p> : !rows.length ? <p className="py-6 text-sm text-text-muted">{search ? "No scopes match this search." : "No scope data available."}</p> : (
<div className="max-h-[390px] overflow-auto"><table aria-label="Scope counts" className="w-full text-left font-mono text-[11px]">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">Scope</th><th scope="col" className="text-right">Packets</th><th scope="col" className="text-right">Observers</th><th scope="col" className="text-right">Nodes</th></tr></thead>
<tbody>{rows.map((row) => <tr key={row.name} className="border-t border-border-subtle">
<th scope="row" className="max-w-32 break-all py-2 pr-2 font-normal text-text-normal">{row.name}</th>
<td className="text-right tabular-nums text-text-bright">{row.packetCount.toLocaleString()}</td><td className="text-right tabular-nums text-text-normal">{row.observerCount.toLocaleString()}</td><td className="text-right tabular-nums text-text-normal">{row.nodeCount.toLocaleString()}</td>
</tr>)}</tbody>
</table></div>
)}
<p className="mt-2 text-[11px] text-text-muted">Charts show the largest 12 scopes plus any remainder. The table keeps all matching scopes.</p>
</Card>
</div>
</div>
);
}
8 changes: 6 additions & 2 deletions src/features/stats/StatsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import { useSearchParams } from "react-router-dom";
import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
import { TrafficTab } from "./TrafficTab";
import { ScopesTab } from "./ScopesTab";
import { TalkersTab } from "./TalkersTab";
import { ClockDriftTab } from "./ClockDriftTab";
import { ObserverTab } from "./ObserverTab";
import { CompareObserversTab } from "./CompareObserversTab";
import { NeighbourGraphTab } from "./NeighbourGraphTab";
import type { StatsRange, StatsTab } from "./types";

const TABS: StatsTab[] = ["mesh", "talkers", "clockdrift", "observer", "compare", "graph"];
const TABS: StatsTab[] = ["mesh", "traffic", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"];
const RANGES: StatsRange[] = ["24h", "7d", "30d"];

const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh");
Expand Down Expand Up @@ -51,10 +53,12 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]);

return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<StatsSubHeader tab={tab} onTabChange={handleTab} range={range} onRangeChange={handleRange} />
<div className="min-h-0 flex-1 overflow-y-auto">
{tab === "mesh" && <MeshTab range={range} onSelectObserver={handleSelectObserver} wsManager={wsManager} />}
{tab === "traffic" && <TrafficTab range={range} />}
{tab === "scopes" && <ScopesTab />}
{tab === "talkers" && <TalkersTab range={range} />}
{tab === "clockdrift" && <ClockDriftTab />}
{tab === "observer" && (
Expand Down
4 changes: 3 additions & 1 deletion src/features/stats/StatsSubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ function GraphIcon() {

const TAB_OPTIONS = [
{ value: "mesh", label: "Mesh", icon: <MeshIcon /> },
{ value: "traffic", label: "Traffic", icon: <MeshIcon /> },
{ value: "scopes", label: "Scopes", icon: <GraphIcon /> },
{ value: "talkers", label: "Talkers", icon: <TalkersIcon /> },
{ value: "clockdrift", label: "Clock Drift", icon: <ClockDriftIcon /> },
{ value: "observer", label: "Observer", icon: <ObserverIcon /> },
Expand Down Expand Up @@ -105,7 +107,7 @@ export function StatsSubHeader({ tab, onTabChange, range, onRangeChange }: Props
</div>
)}
{/* Comparison has explicit dates; graph and clock drift have no rolling window. */}
{tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && (
{tab !== "graph" && tab !== "clockdrift" && tab !== "compare" && tab !== "scopes" && (
<Segmented
className="shrink-0"
options={RANGE_OPTIONS}
Expand Down
57 changes: 57 additions & 0 deletions src/features/stats/TrafficTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { useStatsObservations } from "./useStats";
import { tooltipStyle, useChartColors } from "./chartTheme";
import { donutOption } from "./chartOptions";
import { Card, ChartCard, StatCard } from "./cards";
import { trafficModel, trafficHeatmapOption, trafficTrendOption } from "./traffic";
import type { StatsRange } from "./types";

export function TrafficTab({ range }: { range: StatsRange }) {
const query = useStatsObservations(range);
const colors = useChartColors();
const loading = query.isPending || query.isLoading || query.isPlaceholderData;
const unavailable = loading || query.isError;
const model = useMemo(() => trafficModel(unavailable ? [] : (query.data ?? []), range, query.dataUpdatedAt), [query.data, query.dataUpdatedAt, range, unavailable]);
const trend = useMemo(() => trafficTrendOption(model, colors), [model, colors]);
const heatmap = useMemo(() => trafficHeatmapOption(model, colors), [model, colors]);
const share = useMemo(() => {
const option = donutOption(model.series.map((area, i) => ({ name: area.name, value: area.total, color: colors.series[i] })), colors, formatCount(model.total), "RECEPTIONS");
return { ...option, tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) }, aria: { enabled: true, label: { description: "Reception share by IATA. Exact counts and percentages follow in the table." } } };
}, [model, colors]);
const value = (number: number) => unavailable ? "—" : formatCount(number);

return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div><h2 className="text-lg font-semibold text-text-bright">Traffic</h2><p className="text-sm text-text-muted">When and where observers report receptions.</p></div>
<button type="button" onClick={() => void query.refetch()} disabled={query.isFetching || query.isPending} className="rounded border border-border px-3 py-1.5 text-xs text-text-normal hover:bg-bg-raised disabled:opacity-50">Refresh traffic</button>
</div>
{query.isError && <p role="alert" className="text-sm text-danger">Could not load traffic. Try refreshing.</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Reported receptions" value={value(model.total)} accent={colors.primary} sublabel={range} />
<StatCard label="Reporting IATAs" value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.green} />
<StatCard label="Busiest hour" value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.secondary} />
<StatCard label="Hours with records" value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.warn} />
</div>
<p className="text-xs leading-relaxed text-text-muted">Counts are reported receptions, not unique packets. Blank hours have no retained hourly record; they do not establish an outage. Times are UTC and the latest hour may be partial.</p>
<ChartCard title="Reception trend by IATA" right={<span className="text-[10px] text-text-muted">Available hourly records</span>} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<ChartCard title={`Hourly activity · ${range}`} option={heatmap} height={Math.max(150, model.days.length * 15 + 72)} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Reception share" option={share} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<Card title="Receptions by IATA">
{unavailable ? <p className="py-6 text-sm text-text-muted">{query.isError ? "Data unavailable" : "Loading traffic…"}</p> : !model.areas.length ? <p className="py-6 text-sm text-text-muted">No retained receptions in this window.</p> : (
<div className="max-h-[300px] overflow-y-auto"><table aria-label="Receptions by IATA" className="w-full text-left font-mono text-xs">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">IATA</th><th scope="col" className="text-right">Receptions</th><th scope="col" className="text-right">Share</th></tr></thead>
<tbody>{model.areas.map((area, i) => <tr key={area.name} className="border-t border-border-subtle">
<th scope="row" className="py-2 font-normal text-text-normal"><span aria-hidden className="mr-2 inline-block h-2 w-2 rounded-full" style={{ background: colors.series[Math.min(i, 7)] }} />{area.name}</th>
<td className="text-right tabular-nums text-text-bright">{area.total.toLocaleString()}</td><td className="text-right tabular-nums text-text-muted">{(model.total ? 100 * area.total / model.total : 0).toFixed(1)}%</td>
</tr>)}</tbody>
</table></div>
)}
<p className="mt-2 text-[11px] text-text-muted">Charts group smaller areas into “Other IATAs”; this table keeps every area.</p>
</Card>
</div>
</div>
);
}
7 changes: 6 additions & 1 deletion src/features/stats/echarts-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
// 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, GraphChart } from "echarts/charts";
import { LineChart, BarChart, PieChart, GaugeChart, GraphChart, HeatmapChart } from "echarts/charts";
import {
GridComponent,
TitleComponent,
Expand All @@ -12,6 +12,8 @@ import {
GraphicComponent,
DataZoomComponent,
MarkLineComponent,
VisualMapComponent,
AriaComponent,
} from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";

Expand All @@ -21,13 +23,16 @@ echarts.use([
PieChart,
GaugeChart,
GraphChart,
HeatmapChart,
GridComponent,
TitleComponent,
TooltipComponent,
LegendComponent,
GraphicComponent,
DataZoomComponent,
MarkLineComponent,
VisualMapComponent,
AriaComponent,
CanvasRenderer,
]);

Expand Down
25 changes: 25 additions & 0 deletions src/features/stats/scopes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { ScopeStats } from "./types";
import { leaderboardOption } from "./chartOptions";
import { tooltipStyle, type ChartColors } from "./chartTheme";
import type { EChartsOption } from "./echarts-setup";

export type ScopeMetric = "packetCount" | "observerCount" | "nodeCount";

export function scopeSummary(rows: ScopeStats[]) {
return rows.reduce((summary, row) => ({
active: summary.active + (row.packetCount || row.observerCount || row.nodeCount ? 1 : 0),
packets: summary.packets + row.packetCount,
memberships: summary.memberships + row.observerCount,
nodes: summary.nodes + row.nodeCount,
}), { active: 0, packets: 0, memberships: 0, nodes: 0 });
}

export function scopeChartOption(rows: ScopeStats[], metric: ScopeMetric, colors: ChartColors): EChartsOption {
const indices = new Map(rows.map((row) => row.name).sort().map((name, index) => [name, index]));
const ranked = rows.map((row) => ({ name: row.name, value: row[metric], color: colors.series[(indices.get(row.name) ?? 0) % colors.series.length] ?? colors.primary }))
.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name));
const shown = ranked.slice(0, 12);
if (ranked.length > 12) shown.push({ name: "Other scopes", value: ranked.slice(12).reduce((sum, row) => sum + row.value, 0), color: colors.textDim });
return { ...leaderboardOption(shown, colors, 126), tooltip: { trigger: "item", renderMode: "richText", ...tooltipStyle(colors) },
aria: { enabled: true, label: { description: "Counts by transport scope. Exact values for all matching scopes are listed in the table." } } };
}
Loading
Loading