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
5 changes: 5 additions & 0 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { ObserverSummary, Observer, AdvertObservation } from "../features/o
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
import type {
StatsOverview,
SignalStats,
ObserverComparison,
ObservationPoint,
PayloadBreakdownItem,
Expand Down Expand Up @@ -309,6 +310,10 @@ export function getStatsObservations(iatas?: string[], since?: number, signal?:
return request("/stats/observations", { iatas: iatasParam(iatas), since }, signal);
}

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

export function getPayloadBreakdown(iatas?: string[], since?: number): Promise<PayloadBreakdownItem[]> {
return request("/stats/payload-breakdown", { iatas: iatasParam(iatas), since });
}
Expand Down
73 changes: 73 additions & 0 deletions src/features/stats/SignalTab.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { useMemo } from "react";
import { formatCount } from "../../lib/formatters";
import { Card, ChartCard, StatCard } from "./cards";
import { useChartColors } from "./chartTheme";
import { signalBinLabel, signalCoverageOption, signalHistogramOption, signalHours, signalTrendOption } from "./signal";
import { useSignalStats } from "./useSignalStats";
import type { StatsRange } from "./types";

const utc = (ms: number) => new Date(ms).toISOString().slice(0, 16).replace("T", " ");
const average = (value: number | null | undefined, unit = "") => value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`;

export function SignalTab({ range }: { range: StatsRange }) {
const query = useSignalStats(range);
const c = useChartColors();
const loading = query.isPending || query.isPlaceholderData;
const data = loading || query.isError ? undefined : query.data;
const hours = useMemo(() => signalHours(data), [data]);
const charts = useMemo(() => ({
snr: signalHistogramOption(data?.snr, "SNR", "dB", c), rssi: signalHistogramOption(data?.rssi, "RSSI", "dBm", c),
snrTrend: signalTrendOption(hours, "snr", c), rssiTrend: signalTrendOption(hours, "rssi", c), coverage: signalCoverageOption(data, c),
}), [data, hours, c]);
const state = { isLoading: loading, isError: query.isError };

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">RF / Signal</h2><p className="text-sm text-text-muted">The signal behind reported 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 signal</button>
</div>
{query.isError && <p role="alert" className="text-sm text-danger">Could not load signal data. Try refreshing or choosing a shorter time period.</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label="Reported receptions" value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={range} />
<StatCard label="Mean SNR" value={<span className="whitespace-nowrap text-base sm:text-2xl">{average(data?.snr.average, "dB")}</span>} accent={c.secondary} />
<StatCard label="Mean RSSI" value={<span className="whitespace-nowrap text-base sm:text-2xl">{average(data?.rssi.average, "dBm")}</span>} accent={c.green} />
<StatCard label="Hours with records" value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.warn} />
</div>
<p className="text-xs leading-relaxed text-text-muted">Signal measured by observers on the last hop. Charts count retained receptions and use UTC time.</p>
{data && <p className="text-xs text-text-muted">Window: {utc(data.since)} to {utc(data.until)} UTC (end exclusive). Complete-hour snapshots refresh in the background; the current hour is excluded. Blank hours do not establish an outage.</p>}
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="SNR distribution · dB" option={charts.snr} height={280} isEmpty={!data?.snr.samples} {...state} />
<ChartCard title="RSSI distribution · dBm" option={charts.rssi} height={280} isEmpty={!data?.rssi.samples} {...state} />
<ChartCard title="Hourly mean SNR · dB" option={charts.snrTrend} height={230} isEmpty={!data?.snr.samples} {...state} />
<ChartCard title="Hourly mean RSSI · dBm" option={charts.rssiTrend} height={230} isEmpty={!data?.rssi.samples} {...state} />
</div>
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title="Sample availability" option={charts.coverage} height={150} isEmpty={!data?.receptions} {...state} />
<Card title="What the averages include">
{!data ? <p className="py-4 text-sm text-text-muted">{query.isError ? "Data unavailable" : "Loading signal…"}</p> : !data.receptions ? <p className="py-4 text-sm text-text-muted">No retained receptions in this window.</p> : (
<table aria-label="Signal sample availability" className="w-full text-left font-mono text-xs">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">Metric</th><th scope="col" className="text-right">Samples</th><th scope="col" className="text-right">Missing</th><th scope="col" className="text-right">Share</th></tr></thead>
<tbody>{(["snr", "rssi"] as const).map((metric) => <tr key={metric} className="border-t border-border-subtle"><th scope="row" className="py-3 text-text-normal">{metric.toUpperCase()}</th><td className="text-right text-text-bright">{data[metric].samples.toLocaleString()}</td><td className="text-right text-text-muted">{(data.receptions - data[metric].samples).toLocaleString()}</td><td className="text-right text-text-muted">{(100 * data[metric].samples / data.receptions).toFixed(1)}%</td></tr>)}</tbody>
</table>
)}
<p className="mt-3 text-xs leading-relaxed text-text-muted">Each mean weights every available reception equally. Missing includes absent, invalid and zero/zero unavailable readings; a measured zero SNR remains valid. SNR and RSSI can have different sample counts. These readings do not measure end-to-end quality or packet loss, and colors are not quality thresholds.</p>
</Card>
</div>
{data && data.receptions > 0 && <details className="rounded-lg border border-border bg-bg-surface p-3.5">
<summary className="cursor-pointer text-sm font-semibold text-text-normal">Exact histogram and hourly values</summary>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
{(["snr", "rssi"] as const).map((metric) => <table key={metric} aria-label={`${metric.toUpperCase()} histogram`} className="w-full text-left font-mono text-xs">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">{metric.toUpperCase()} · {metric === "snr" ? "dB" : "dBm"}</th><th scope="col" className="text-right">Samples</th></tr></thead>
<tbody>{data[metric].histogram.map((bin, i) => <tr key={i} className="border-t border-border-subtle"><th scope="row" className="py-1.5 font-normal text-text-normal">{signalBinLabel(bin)}</th><td className="text-right text-text-bright">{bin.count.toLocaleString()}</td></tr>)}</tbody>
</table>)}
</div>
<p className="my-3 text-xs text-text-muted">Bins include the lower bound and exclude the upper bound. The outer bins include all values beyond the display range. Hourly means below are rounded to one decimal place.</p>
<div className="max-h-[340px] overflow-auto"><table aria-label="Hourly signal values" className="w-full min-w-[540px] text-left font-mono text-xs">
<thead className="text-text-muted"><tr><th scope="col" className="py-2">UTC hour</th><th scope="col">Receptions</th><th scope="col">SNR samples</th><th scope="col">Mean dB</th><th scope="col">RSSI samples</th><th scope="col">Mean dBm</th></tr></thead>
<tbody>{data.hourly.map((row) => <tr key={row.hour} className="border-t border-border-subtle"><th scope="row" className="py-2 font-normal text-text-normal">{utc(row.hour)}</th><td>{row.receptions.toLocaleString()}</td><td>{row.snrSamples.toLocaleString()}</td><td>{average(row.snrAverage)}</td><td>{row.rssiSamples.toLocaleString()}</td><td>{average(row.rssiAverage)}</td></tr>)}</tbody>
</table></div>
</details>}
</div>
);
}
4 changes: 3 additions & 1 deletion src/features/stats/StatsOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { WsManager } from "../../api/ws-manager";
import { StatsSubHeader } from "./StatsSubHeader";
import { MeshTab } from "./MeshTab";
import { TrafficTab } from "./TrafficTab";
import { SignalTab } from "./SignalTab";
import { ScopesTab } from "./ScopesTab";
import { TalkersTab } from "./TalkersTab";
import { ClockDriftTab } from "./ClockDriftTab";
Expand All @@ -12,7 +13,7 @@ import { CompareObserversTab } from "./CompareObserversTab";
import { NeighbourGraphTab } from "./NeighbourGraphTab";
import type { StatsRange, StatsTab } from "./types";

const TABS: StatsTab[] = ["mesh", "traffic", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"];
const TABS: StatsTab[] = ["mesh", "traffic", "signal", "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 @@ -58,6 +59,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) {
<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 === "signal" && <SignalTab range={range} />}
{tab === "scopes" && <ScopesTab />}
{tab === "talkers" && <TalkersTab range={range} />}
{tab === "clockdrift" && <ClockDriftTab />}
Expand Down
7 changes: 7 additions & 0 deletions src/features/stats/StatsSubHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ function ObserverIcon() {
);
}

function SignalIcon() {
return <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
<path d="M1 7h2l2-4 3.5 8L11 5l1 2h1" strokeLinecap="round" strokeLinejoin="round" />
</svg>;
}

function TalkersIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.3" aria-hidden>
Expand Down Expand Up @@ -76,6 +82,7 @@ function GraphIcon() {
const TAB_OPTIONS = [
{ value: "mesh", label: "Mesh", icon: <MeshIcon /> },
{ value: "traffic", label: "Traffic", icon: <TrafficIcon /> },
{ value: "signal", label: "RF / Signal", icon: <SignalIcon /> },
{ value: "scopes", label: "Scopes", icon: <ScopesIcon /> },
{ value: "talkers", label: "Talkers", icon: <TalkersIcon /> },
{ value: "clockdrift", label: "Clock Drift", icon: <ClockDriftIcon /> },
Expand Down
69 changes: 69 additions & 0 deletions src/features/stats/signal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { EChartsOption } from "echarts";
import type { SignalBin, SignalMetric, SignalStats } from "./types";
import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme";

const HOUR = 3_600_000;
export function signalHours(data: SignalStats | undefined) {
if (!data || data.until <= data.since) return [];
const first = Math.floor(data.since / HOUR) * HOUR;
const rows = new Map(data.hourly.map((row) => [row.hour, row]));
return Array.from({ length: Math.min(721, Math.ceil((data.until - first) / HOUR)) }, (_, i) => {
const hour = first + i * HOUR, row = rows.get(hour);
return {
hour, receptions: row?.receptions ?? null,
snrAverage: row && row.snrSamples > 0 ? row.snrAverage : null,
rssiAverage: row && row.rssiSamples > 0 ? row.rssiAverage : null,
};
});
}

export function signalBinLabel(bin: SignalBin) {
if (bin.lower === null) return `< ${bin.upper}`;
if (bin.upper === null) return `≥ ${bin.lower}`;
return `${bin.lower} to < ${bin.upper}`;
}

export function signalHistogramOption(metric: SignalMetric | undefined, name: string, unit: string, c: ChartColors): EChartsOption {
const bins = metric?.histogram ?? [];
return {
animation: false,
aria: { enabled: true, label: { description: `${name} distribution in ${unit}. Exact bin boundaries and counts follow below.` } },
grid: { left: 8, right: 10, top: 20, bottom: 16, containLabel: true },
tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) },
xAxis: { type: "category", data: bins.map((bin) => `${signalBinLabel(bin)} ${unit}`), axisLabel: { color: c.textMuted, fontSize: 9, rotate: 35 }, axisLine: { lineStyle: { color: c.border } } },
yAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } },
series: [{ name: `${name} samples`, type: "bar", barMaxWidth: 28, data: bins.map((bin, i) => ({ value: bin.count, itemStyle: { color: blend(name === "SNR" ? c.secondary : c.green, c.primary, i / Math.max(1, bins.length - 1)), borderRadius: [3, 3, 0, 0] } })) }],
};
}

export function signalTrendOption(hours: ReturnType<typeof signalHours>, metric: "snr" | "rssi", c: ChartColors): EChartsOption {
const color = metric === "snr" ? c.secondary : c.green;
const unit = metric === "snr" ? "dB" : "dBm";
return {
animation: false, useUTC: true,
aria: { enabled: true, label: { description: `Hourly mean ${metric.toUpperCase()} in ${unit}, UTC. Missing readings remain gaps. Exact values are in the hourly table.` } },
grid: { left: 8, right: 18, top: 20, bottom: 16, containLabel: true },
tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c), valueFormatter: (value) => typeof value === "number" ? `${value.toFixed(2)} ${unit}` : "No sample" },
xAxis: { type: "time", axisLabel: { color: c.textMuted, fontSize: 10, hideOverlap: true }, axisLine: { lineStyle: { color: c.border } } },
yAxis: { type: "value", scale: true, axisLabel: { color: c.textMuted, fontSize: 10 }, splitLine: { lineStyle: { color: c.borderSubtle } } },
series: [{ name: `${metric.toUpperCase()} · ${unit}`, type: "line", connectNulls: false, showSymbol: true, symbolSize: 5,
lineStyle: { width: 2, color }, itemStyle: { color }, areaStyle: { color: withAlpha(color, 0.08) },
data: hours.map((hour) => [hour.hour, hour[metric === "snr" ? "snrAverage" : "rssiAverage"]]) }],
};
}

export function signalCoverageOption(data: SignalStats | undefined, c: ChartColors): EChartsOption {
return {
animation: false,
aria: { enabled: true, label: { description: "Available and unavailable readings for SNR and RSSI. Exact counts and percentages are in the sample availability table." } },
grid: { left: 8, right: 12, top: 30, bottom: 12, containLabel: true },
legend: { top: 0, textStyle: { color: c.textMuted } },
tooltip: { trigger: "axis", renderMode: "richText", ...tooltipStyle(c) },
xAxis: { type: "value", minInterval: 1, axisLabel: { color: c.textMuted }, splitLine: { lineStyle: { color: c.borderSubtle } } },
yAxis: { type: "category", data: ["SNR", "RSSI"], axisLabel: { color: c.textNormal }, axisLine: { show: false }, axisTick: { show: false } },
series: [
{ name: "Available", type: "bar", stack: "samples", barMaxWidth: 22, itemStyle: { color: c.primary }, data: [data?.snr.samples ?? 0, data?.rssi.samples ?? 0] },
{ name: "Unavailable", type: "bar", stack: "samples", itemStyle: { color: c.warn }, data: [data ? data.receptions - data.snr.samples : 0, data ? data.receptions - data.rssi.samples : 0] },
],
};
}
22 changes: 21 additions & 1 deletion src/features/stats/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,11 +153,31 @@ export interface ObserverActivity {
}

// Sub-tab + time-range identifiers shared across the Stats page.
export type StatsTab = "mesh" | "traffic" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
export type StatsTab = "mesh" | "traffic" | "signal" | "scopes" | "talkers" | "clockdrift" | "observer" | "compare" | "graph";
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,
};

// beacon-server /stats/signal: retained observation rows in [since, until).
export interface SignalBin { lower: number | null; upper: number | null; count: number }
export interface SignalMetric { samples: number; average: number | null; histogram: SignalBin[] }
export interface SignalHour {
hour: number;
receptions: number;
snrSamples: number;
snrAverage: number | null;
rssiSamples: number;
rssiAverage: number | null;
}
export interface SignalStats {
since: number;
until: number;
receptions: number;
snr: SignalMetric;
rssi: SignalMetric;
hourly: SignalHour[];
}
22 changes: 22 additions & 0 deletions src/features/stats/useSignalStats.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { useQuery } from "@tanstack/react-query";
import { getSignalStats } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { RANGE_MS, type StatsRange } from "./types";

export function useSignalStats(range: StatsRange) {
const { iatas, regionKey, isResolved } = useRegion();
return useQuery({
queryKey: ["stats-signal", isResolved === false ? `${regionKey}:pending` : regionKey, range],
enabled: isResolved !== false,
queryFn: ({ signal }) => {
if (isResolved === false) throw new Error("Selected region is not available yet");
// Shared minute boundaries let viewers reuse server aggregates without changing the key every render.
const until = Math.floor(Date.now() / 60_000) * 60_000;
return getSignalStats(until - RANGE_MS[range], until, iatas, signal);
},
staleTime: 30_000,
refetchInterval: 60_000,
refetchOnWindowFocus: false,
retry: false,
});
}
13 changes: 12 additions & 1 deletion tests/api/analytics-client.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { afterEach, expect, it, vi } from "vitest";
import { getStatsObservations, getStatsScopes } from "../../src/api/client";
import { getStatsObservations, getStatsScopes, getSignalStats } from "../../src/api/client";

afterEach(() => vi.unstubAllGlobals());

Expand All @@ -18,3 +18,14 @@ it("forwards region and cancellation to both aggregate endpoints, omitting an em
expect(url.searchParams.get("since")).toBe("1234");
expect(url.searchParams.get("iatas")).toBe("YOW");
});

it("sends explicit signal window bounds, regional filters and cancellation", async () => {
const fetcher = vi.fn<typeof fetch>(async () => ({ ok: true, json: async () => ({}) }) as Response);
vi.stubGlobal("fetch", fetcher);
const controller = new AbortController();
await getSignalStats(0, 1000, ["YVR", "YYJ"], controller.signal);
const url = new URL(fetcher.mock.calls[0]![0] as string);
expect(url.pathname).toContain("/stats/signal");
expect(Object.fromEntries(url.searchParams)).toEqual({ since: "0", until: "1000", iatas: "YVR,YYJ" });
expect(fetcher.mock.calls[0]![1]).toEqual({ signal: controller.signal });
});
Loading
Loading