Sync dev1 Atlas with current upstream web and paired backend APIs

This commit is contained in:
n30nex committed 2026-10-12 14:44:48 -04:00
commit 6f94e88540
30 files changed
+583 -82

No files matched your search

+5 -5
View File
@@ -1,12 +1,12 @@
{
"name": "beacon-web",
"version": "2.0.5",
"version": "2.0.5-dev1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "beacon-web",
"version": "2.0.5",
"version": "2.0.5-dev1.1",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@nazka/map-gl-js-spiderfy": "^2.0.0",
@@ -4094,9 +4094,9 @@
"license": "ISC"
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "beacon-web",
"private": true,
"version": "2.0.5",
"version": "2.0.5-dev1.1",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
+3 -2
View File
@@ -13,6 +13,7 @@ import type {
PayloadBreakdownItem,
TopNode,
TopObserver,
AdvertiserSort,
TopAdvertiser,
TopTalker,
RadioPreset,
@@ -422,8 +423,8 @@ export function getTopObservers(iatas?: StatsRegion, since?: number, limit = 10)
return request("/stats/top-observers", { ...statsRegionParams(iatas), since, limit });
}
export function getTopAdvertisers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, limit });
export function getTopAdvertisers(iatas: StatsRegion | undefined, since: number | undefined, sort: AdvertiserSort, limit = 10): Promise<TopAdvertiser[]> {
return request("/stats/top-advertisers", { ...statsRegionParams(iatas), since, sort, limit });
}
export function getTopTalkers(iatas?: StatsRegion, since?: number, limit = 10): Promise<TopTalker[]> {
+2 -2
View File
@@ -1,10 +1,10 @@
import type { ReactNode } from "react";
import { VARIANT_CLASSES, type BadgeVariant } from "./badge-utils";
export function Badge({ variant, children }: { variant: BadgeVariant; children: ReactNode }) {
export function Badge({ variant, children, normalCase = false }: { variant: BadgeVariant; children: ReactNode; normalCase?: boolean }) {
return (
<span
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider uppercase ${VARIANT_CLASSES[variant]}`}
className={`inline-flex items-center font-mono text-[11px] font-semibold px-2 py-0.5 rounded-sm border tracking-wider ${normalCase ? "" : "uppercase "}${VARIANT_CLASSES[variant]}`}
>
{children}
</span>
+7 -2
View File
@@ -1,11 +1,13 @@
import { useState, useRef, useEffect, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { useState, useRef, useEffect, useId, useLayoutEffect, type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { useHasHover } from "../hooks/useMediaQuery";
// Portals to <body> with fixed positioning so overflow parents (the data tables) can't clip it.
// Hover reveals with a mouse; on touch it toggles on tap and dismisses on an outside tap.
export function Tooltip({ label, children, className = "", wrap = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean }) {
// `focusable` makes a non-interactive trigger a tab stop whose label is exposed to screen readers.
export function Tooltip({ label, children, className = "", wrap = false, focusable = false }: { label: ReactNode; children: ReactNode; className?: string; wrap?: boolean; focusable?: boolean }) {
const hasHover = useHasHover();
const descId = useId();
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
const [anchor, setAnchor] = useState<DOMRect | null>(null);
@@ -62,9 +64,12 @@ export function Tooltip({ label, children, className = "", wrap = false }: { lab
onClick={hasHover ? undefined : toggle}
onFocus={hasHover ? show : undefined}
onBlur={hasHover ? hide : undefined}
tabIndex={focusable ? 0 : undefined}
aria-describedby={focusable ? descId : undefined}
className={`inline-flex ${className}`}
>
{children}
{focusable && <span id={descId} hidden>{label}</span>}
{anchor &&
createPortal(
<span
+1 -1
View File
@@ -6,7 +6,7 @@ export function ForeignNodeBadge({ possiblyForeign }: { possiblyForeign?: boolea
const { t } = useTranslation();
if (possiblyForeign !== true) return null;
return (
<Tooltip label={t("nodes.possiblyForeignHint")}>
<Tooltip label={t("nodes.possiblyForeignHint")} focusable>
<Badge variant="default">{t("nodes.possiblyForeign")}</Badge>
</Tooltip>
);
+91
View File
@@ -0,0 +1,91 @@
import { useTranslation } from "react-i18next";
import { Section, Field } from "../../components/DetailPanel";
import { IataChip } from "../../components/IataChip";
import { Timestamp } from "../../components/Timestamp";
import type { MeshMapperRepeater, MeshMapperScope } from "./types";
function ScopeRow({ scope }: { scope: MeshMapperScope }) {
const { t } = useTranslation();
const how = [
scope.observedPackets != null ? t("nodeDetail.observedPackets", { count: scope.observedPackets }) : scope.observedAt != null ? t("nodeDetail.observed") : null,
scope.reportedAt != null ? t("nodeDetail.reported") : null,
scope.uploadedAt != null ? t("nodeDetail.uploaded") : null,
].filter(Boolean).join(" · ");
const latest = Math.max(scope.observedAt ?? 0, scope.reportedAt ?? 0, scope.uploadedAt ?? 0);
return (
<div className="flex items-baseline gap-2 min-w-0">
<span className="text-text-normal truncate min-w-0" title={scope.scope}>{scope.scope}</span>
{how && <span className="text-text-muted text-[11px] truncate min-w-0">{how}</span>}
{latest > 0 && <Timestamp value={latest} className="ml-auto shrink-0 text-text-dim text-[11px]" />}
</div>
);
}
export function MeshMapperSection({ mm }: { mm: MeshMapperRepeater }) {
const { t, i18n } = useTranslation();
const { site, defaultScope } = mm;
const hasSite = Boolean(site.hardware || site.power || site.powerSource || site.antenna || site.heightM != null || site.notes);
const hasScopes = mm.scopes.length > 0 || mm.scopesCheckedAt != null;
// carpeater alone is covered by the badge next to the node name
if (!hasSite && !hasScopes && mm.admins.length === 0 && !defaultScope) return null;
const num = new Intl.NumberFormat(i18n.language, { maximumFractionDigits: 1 });
const height = site.heightM != null
? [t("nodeDetail.heightAgl", { value: num.format(site.heightM) }), site.heightAslM != null ? t("nodeDetail.heightAsl", { value: num.format(site.heightAslM) }) : null].filter(Boolean).join(" · ")
: null;
return (
<Section title={t("nodeDetail.meshmapper")}>
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{(site.hardware || site.power || site.powerSource) && (
<div className="flex flex-wrap gap-x-4 gap-y-0.5">
{site.hardware && <Field label={t("nodeDetail.hardware")} value={site.hardware} />}
{site.power && <Field label={t("nodeDetail.power")} value={site.power} />}
{site.powerSource && <Field label={t("nodeDetail.powerSource")} value={t(`nodeDetail.powerSources.${site.powerSource}`, { defaultValue: site.powerSource })} />}
</div>
)}
{site.antenna && <Field label={t("nodeDetail.antenna")} value={site.antenna} />}
{height && <Field label={t("nodeDetail.height")} value={height} />}
{mm.admins.length > 0 && <Field label={t("nodeDetail.admins")} value={mm.admins.join(", ")} />}
{defaultScope && (
<Field
label={t("nodeDetail.mmDefaultScope")}
value={
<>
{defaultScope.state === "named" && defaultScope.name ? defaultScope.name : t(`nodeDetail.scopeState.${defaultScope.state === "none" ? "none" : "unknown"}`)}
{(defaultScope.source || defaultScope.updatedAt != null) && (
<span className="text-text-dim text-[11px]">
{" "}({defaultScope.source && t(`nodeDetail.scopeSource.${defaultScope.source}`, { defaultValue: defaultScope.source })}
{defaultScope.source && defaultScope.updatedAt != null && ", "}
{defaultScope.updatedAt != null && <Timestamp value={defaultScope.updatedAt} />})
</span>
)}
</>
}
/>
)}
{hasScopes && (
<div>
<span className="text-text-dim">{t("nodeDetail.confirmedScopes")}</span>
<div className="pl-3 flex flex-col gap-0.5">
{mm.scopes.length > 0 ? (
mm.scopes.map((s, i) => <ScopeRow key={`${s.scope}-${i}`} scope={s} />)
) : (
<div className="flex items-baseline gap-2">
<span className="text-text-muted">{t("nodeDetail.noConfirmedScopes")}</span>
<Timestamp value={mm.scopesCheckedAt!} className="ml-auto text-text-dim text-[11px]" />
</div>
)}
</div>
</div>
)}
{site.notes && <p className="mt-1 text-text-muted whitespace-pre-line break-words">{site.notes}</p>}
</div>
<div className="mt-2 flex items-center gap-1.5 font-mono text-[11px] text-text-dim">
<span>{t("nodeDetail.fromMeshMapper")}</span>
{mm.iata && <IataChip>{mm.iata}</IataChip>}
<span className="ml-auto">{t("nodeDetail.updated")} <Timestamp value={mm.fetchedAt} /></span>
</div>
</Section>
);
}
+9
View File
@@ -11,6 +11,8 @@ import { Timestamp } from "../../components/Timestamp";
import { hasMapLocation } from "../map/location";
import type { NodeObservation, NodeNeighbor } from "./types";
import { ForeignNodeBadge } from "./ForeignNodeBadge";
import { MeshMapperSection } from "./MeshMapperSection";
import { Tooltip } from "../../components/Tooltip";
function NodeNeighborRow({ neighbor, onClick }: { neighbor: NodeNeighbor; onClick?: () => void }) {
const { t } = useTranslation();
@@ -126,6 +128,11 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</span>
<Badge variant="default">{node.nodeTypeName}</Badge>
<ForeignNodeBadge possiblyForeign={node.possiblyForeign} />
{node.meshmapper?.carpeater && (
<Tooltip label={t("nodeDetail.carpeaterHint")} focusable>
<Badge variant="trace" normalCase>{t("nodeDetail.carpeater")}</Badge>
</Tooltip>
)}
</div>
<div className="flex items-center gap-2">
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={node.publicKey}>
@@ -163,6 +170,8 @@ export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onViewNode, o
</Section>
)}
{node.meshmapper && <MeshMapperSection mm={node.meshmapper} />}
<Section title={t("nodeDetail.capabilities")}>
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{node.minFirmwareVersion && <Field label={t("nodeDetail.minFirmware")} value={node.minFirmwareVersion} />}
+40
View File
@@ -40,6 +40,46 @@ export interface Node extends NodeSummary {
clockDriftSeconds?: number;
clockOutOfSync?: boolean;
clockCheckedAt?: number; // epoch ms
// MeshMapper's record for this repeater; node detail only, absent when the import is off or unmatched.
meshmapper?: MeshMapperRepeater;
}
// Imported from MeshMapper. Times are epoch ms; every string is admin-entered free text.
export interface MeshMapperRepeater {
iata?: string;
fetchedAt: number;
site: MeshMapperSite;
carpeater: boolean;
admins: string[];
scopes: MeshMapperScope[];
scopesCheckedAt?: number; // set with empty scopes = it answered with none; absent = never asked
defaultScope?: MeshMapperDefaultScope; // separate from Node.defaultScope (Beacon's own match)
}
export interface MeshMapperSite {
power?: string;
hardware?: string;
antenna?: string;
heightM?: number; // above ground
heightAslM?: number;
powerSource?: "mains" | "solar" | "battery" | "poe";
notes?: string;
}
export interface MeshMapperScope {
scope: string;
observedAt?: number;
observedPackets?: number;
proof?: "multibyte" | "floor";
reportedAt?: number;
uploadedAt?: number;
}
export interface MeshMapperDefaultScope {
name?: string; // only when state is "named"
state: "named" | "unknown" | "none";
updatedAt?: number;
source?: "advert" | "reported";
}
// First-hop neighbor of a node, from GET /nodes/{id}/neighbors (bare array, no pagination).
@@ -48,7 +48,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window, dataB) : [];
return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 },
legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } },
series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({
series: [["A", aPoints, colors.series[0]], ["B", bPoints, colors.series[2]]].map(([name, points, color]) => ({
name: name as string, type: "line" as const, symbol: "none", connectNulls: false,
data: (points as typeof aPoints).map(p => [p.t, p.observations]),
lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string },
@@ -57,9 +57,9 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
}, [activityA, dataB, aligned, intervalMs, start, end, colors]);
const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" });
const groups = overlap.data && !overlap.isError ? [
{ label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary, textClass: "text-primary" },
{ label: t("observerCompare.both"), count: overlap.data.both, color: colors.green, textClass: "text-green" },
{ label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary, textClass: "text-secondary" },
{ label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.series[0]! },
{ label: t("observerCompare.both"), count: overlap.data.both, color: colors.series[1]! },
{ label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.series[2]! },
] : [];
const refresh = () => {
const nextUntil = onRefresh();
@@ -92,7 +92,7 @@ export function ObserverComparison({ observerA, activityA, range, observerBId, u
{overlap.isError ? <p role="alert">{t("common.loadFailed")}</p> : !overlap.data ? <p role="status">{t("common.loading")}</p> : <>
<p className="mb-3 text-lg font-semibold">{t("observerCompare.total", { count: overlap.data.totalPackets })}</p>
{overlap.data.totalPackets === 0 ? <p>{t("observerCompare.empty")}</p> : <div aria-hidden className="mb-3 flex h-5 overflow-hidden rounded">{groups.map(g => <div key={g.label} style={{ width: `${g.count / overlap.data!.totalPackets * 100}%`, background: g.color }} />)}</div>}
<dl className="grid grid-cols-3 gap-2 text-sm">{groups.map(g => <div key={g.label}><dt className="text-text-muted">{g.label}</dt><dd className={`text-lg font-semibold ${g.textClass}`}>{g.count.toLocaleString(i18n.resolvedLanguage)}</dd></div>)}</dl>
<dl className="grid grid-cols-3 gap-2 text-sm">{groups.map(g => <div key={g.label}><dt className="text-text-muted">{g.label}</dt><dd className="text-lg font-semibold" style={{ color: g.color }}>{g.count.toLocaleString(i18n.resolvedLanguage)}</dd></div>)}</dl>
</>}
</Card>}
</>}
+4 -4
View File
@@ -126,10 +126,10 @@ export function MeshTab({ range, onSelectObserver }: MeshTabProps) {
return (
<div className="mx-auto flex w-full min-w-0 max-w-[1200px] flex-col gap-3.5 p-4">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent="var(--color-primary)" value={formatCount(kpis?.uniquePackets)} spark={sparks.packets} />
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent="var(--color-green)" value={formatCount(kpis?.observations)} spark={sparks.observations} />
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent="var(--color-secondary)" value={kpis?.activeObservers ?? "—"} spark={sparks.observers} />
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent="var(--color-warn)" value={kpis?.activeIatas ?? "—"} spark={sparks.iatas} />
<StatCard label={t("mesh.totalPackets")} sublabel={ovWindow} accent={colors.series[0]!} value={formatCount(kpis?.uniquePackets)} spark={sparks.packets} />
<StatCard label={t("mesh.observations")} sublabel={ovWindow} accent={colors.series[1]!} value={formatCount(kpis?.observations)} spark={sparks.observations} />
<StatCard label={t("mesh.activeObservers")} sublabel={ovWindow} accent={colors.series[2]!} value={kpis?.activeObservers ?? "—"} spark={sparks.observers} />
<StatCard label={t("mesh.activeIatas")} sublabel={ovWindow} accent={colors.series[3]!} value={kpis?.activeIatas ?? "—"} spark={sparks.iatas} />
</div>
<ChartCard
+7 -7
View File
@@ -17,10 +17,10 @@ export function PathsTab({ range }: { range: StatsRange }) {
const data = loading || query.isError ? undefined : query.data;
const hours = useMemo(() => pathHours(data), [data]);
const categories = useMemo(() => [
{ name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.primary },
{ name: t("paths.categories.hashed"), value: data?.hashed ?? 0, color: c.series[0]! },
{ name: t("paths.categories.empty"), value: data?.empty ?? 0, color: c.textDim },
{ name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.warn },
{ name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.secondary },
{ name: t("paths.categories.trace"), value: data?.trace ?? 0, color: c.series[3]! },
{ name: t("paths.categories.unclassified"), value: data?.unclassified ?? 0, color: c.series[2]! },
], [data, c, t]);
const charts = useMemo(() => {
const width = donutOption((data?.hashWidths ?? []).map((bin, i) => ({ name: t("paths.hashWidth", { count: bin.bytes }), value: bin.receptions, color: c.series[i] })), c, formatCount(data?.hashed ?? 0), t("paths.hashPathsCenter"));
@@ -49,10 +49,10 @@ export function PathsTab({ range }: { range: StatsRange }) {
<SectionInfo text={[t("paths.subtitle"), t("paths.measurement"), data && t("paths.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("paths.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks.receptions} />
<StatCard label={t("paths.withHashPaths")} value={data ? formatCount(data.hashed) : "—"} accent={c.green} spark={sparks.hashed} />
<StatCard label={t("paths.multiByteShare")} value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.secondary} spark={sparks.multiShare} />
<StatCard label={t("paths.mostEntries")} value={largest ?? "—"} accent={c.warn} spark={sparks.maxEntries} />
<StatCard label={t("paths.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.series[0]!} sublabel={t(`stats.ranges.${range}`)} spark={sparks.receptions} />
<StatCard label={t("paths.withHashPaths")} value={data ? formatCount(data.hashed) : "—"} accent={c.series[1]!} spark={sparks.hashed} />
<StatCard label={t("paths.multiByteShare")} value={data?.hashed ? `${(100 * multi / data.hashed).toFixed(1)}%` : "—"} accent={c.series[2]!} spark={sparks.multiShare} />
<StatCard label={t("paths.mostEntries")} value={largest ?? "—"} accent={c.series[3]!} spark={sparks.maxEntries} />
</div>
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title={t("paths.widthTitle")} option={charts.width} height={260} isEmpty={!data?.hashed} {...state} />
+4 -4
View File
@@ -41,10 +41,10 @@ export function ScopesTab({ range }: { range: StatsRange }) {
</label>
{query.isError && <p role="alert" className="text-sm text-danger">{t("scopes.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("scopes.active")} value={value(totals.active)} accent={colors.secondary} spark={sparks?.active} />
<StatCard label={t("scopes.scopedPackets")} value={packetsUnknown ? "—" : value(totals.packets)} accent={colors.primary} spark={sparks?.packets} />
<StatCard label={t("scopes.memberships")} value={value(totals.memberships)} accent={colors.green} spark={sparks?.observers} sublabel={sparks?.observers && t("scopes.observersLine")} />
<StatCard label={t("scopes.defaultNodes")} value={value(totals.nodes)} accent={colors.warn} spark={sparks?.nodes} sublabel={sparks?.nodes && t("scopes.nodesLine")} />
<StatCard label={t("scopes.active")} value={value(totals.active)} accent={colors.series[2]!} spark={sparks?.active} />
<StatCard label={t("scopes.scopedPackets")} value={packetsUnknown ? "—" : value(totals.packets)} accent={colors.series[0]!} spark={sparks?.packets} />
<StatCard label={t("scopes.memberships")} value={value(totals.memberships)} accent={colors.series[1]!} spark={sparks?.observers} sublabel={sparks?.observers && t("scopes.observersLine")} />
<StatCard label={t("scopes.defaultNodes")} value={value(totals.nodes)} accent={colors.series[3]!} spark={sparks?.nodes} sublabel={sparks?.nodes && t("scopes.nodesLine")} />
</div>
<div className="grid grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title={t("scopes.packetsTitle")} option={packets} height={height} isLoading={loading} isError={query.isError} isEmpty={!totals.packets} />
+4 -4
View File
@@ -33,10 +33,10 @@ export function SignalTab({ range }: { range: StatsRange }) {
<SectionInfo text={[t("signal.subtitle"), t("signal.measurement"), data && t("signal.window", { since: formatUtc(data.since), until: formatUtc(data.until) })]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("signal.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.receptions} />
<StatCard label={t("signal.meanSnr")} value={average(data?.snr.average, "dB")} accent={c.secondary} spark={sparks?.snr} />
<StatCard label={t("signal.meanRssi")} value={average(data?.rssi.average, "dBm")} accent={c.green} spark={sparks?.rssi} />
<StatCard label={t("signal.hoursWithRecords")} value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.warn} presence={sparks?.reported} />
<StatCard label={t("signal.receptions")} value={data ? formatCount(data.receptions) : "—"} accent={c.series[0]!} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.receptions} />
<StatCard label={t("signal.meanSnr")} value={average(data?.snr.average, "dB")} accent={c.series[2]!} spark={sparks?.snr} />
<StatCard label={t("signal.meanRssi")} value={average(data?.rssi.average, "dBm")} accent={c.series[1]!} spark={sparks?.rssi} />
<StatCard label={t("signal.hoursWithRecords")} value={data ? `${data.hourly.length}/${hours.length}` : "—"} accent={c.series[3]!} presence={sparks?.reported} />
</div>
<div className="grid min-w-0 grid-cols-1 gap-3.5 lg:grid-cols-2">
<ChartCard title={t("signal.histogramTitle", { metric: "SNR", unit: "dB" })} option={charts.snr} height={280} isEmpty={!data?.snr.samples} {...state} />
+14 -4
View File
@@ -1,15 +1,16 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useChartColors } from "./chartTheme";
import { rolledWindow, useTopAdvertisers, useTopTalkers } from "./useStats";
import { leaderboardOption } from "./chartOptions";
import { Card, ChartCard } from "./cards";
import { Segmented } from "./Segmented";
import { DataTable, type Column } from "../../components/DataTable";
import { Badge } from "../../components/Badge";
import { IataChip } from "../../components/IataChip";
import { formatCount, formatRatePerDay } from "../../lib/formatters";
import { RANGE_MS } from "./types";
import type { TopAdvertiser, StatsRange } from "./types";
import type { AdvertiserSort, TopAdvertiser, StatsRange } from "./types";
interface TalkersTabProps {
range: StatsRange;
@@ -32,7 +33,8 @@ function coveredMs(range: StatsRange, fetchedAt: number) {
export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
const { t } = useTranslation();
const colors = useChartColors();
const topAdvertisers = useTopAdvertisers(range, 20);
const [advertSort, setAdvertSort] = useState<AdvertiserSort>("flood");
const topAdvertisers = useTopAdvertisers(range, advertSort, 20);
const topTalkers = useTopTalkers(range, 20);
const advertisersLoading = topAdvertisers.isPending || topAdvertisers.isPlaceholderData;
@@ -76,7 +78,15 @@ export function TalkersTab({ range, onViewNode }: TalkersTabProps) {
const rangeLabel = t(`stats.ranges.${range}`);
return (
<div className="mx-auto grid w-full min-w-0 max-w-[1200px] grid-cols-1 items-start gap-3.5 p-4 lg:grid-cols-2">
<Card title={t("talkers.topAdvertisers", { range: rangeLabel })} right={<span className="font-mono text-[10px] text-text-muted">{t("talkers.floodDirect")}</span>}>
<Card title={t("talkers.topAdvertisers", { range: rangeLabel })} right={
<Segmented
size="xs"
ariaLabel={t("talkers.sortBy")}
value={advertSort}
onChange={(v) => setAdvertSort(v as AdvertiserSort)}
options={[{ value: "flood", label: t("talkers.flood") }, { value: "direct", label: t("talkers.direct") }]}
/>
}>
<div className="flex flex-col" style={{ height: leaderboardHeight(advertisers.length) }}>
<DataTable
columns={advertiserColumns}
+4 -4
View File
@@ -35,10 +35,10 @@ export function TrafficTab({ range }: { range: StatsRange }) {
<SectionInfo text={[t("traffic.subtitle"), t("traffic.measurement")]} />
{query.isError && <p role="alert" className="text-sm text-danger">{t("traffic.error")}</p>}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.primary} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.totals} />
<StatCard label={t("traffic.reportingIatas")} value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.green} spark={sparks?.areas} />
<StatCard label={t("traffic.busiestHour")} value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.secondary} spark={sparks?.totals} markPeak />
<StatCard label={t("traffic.hoursWithRecords")} value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.warn} presence={sparks?.reported} />
<StatCard label={t("traffic.receptions")} value={value(model.total)} accent={colors.series[0]!} sublabel={t(`stats.ranges.${range}`)} spark={sparks?.totals} />
<StatCard label={t("traffic.reportingIatas")} value={value(model.areas.filter((area) => area.total > 0).length)} accent={colors.series[1]!} spark={sparks?.areas} />
<StatCard label={t("traffic.busiestHour")} value={unavailable || !model.peak ? "—" : formatCount(model.peak.total)} accent={colors.series[2]!} spark={sparks?.totals} markPeak />
<StatCard label={t("traffic.hoursWithRecords")} value={unavailable ? "—" : `${model.reportedHours}/${model.hours.length}`} accent={colors.series[3]!} presence={sparks?.reported} />
</div>
<ChartCard title={t("traffic.trendTitle")} right={<span className="text-[10px] text-text-muted">{t("traffic.hourlyRecords")}</span>} option={trend} height={260} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
<ChartCard title={t("traffic.hourlyTitle", { range: t(`stats.ranges.${range}`) })} option={heatmap} height={Math.max(150, model.days.length * 15 + 72)} isLoading={loading} isError={query.isError} isEmpty={!model.total} />
+10 -10
View File
@@ -59,15 +59,15 @@ export function observationsAreaOption(
smooth: true,
symbol: "none",
data: points.map((p) => [p.hour, p.observations]),
lineStyle: { color: c.primary, width: 2 },
itemStyle: { color: c.primary },
lineStyle: { color: c.series[0], width: 2 },
itemStyle: { color: c.series[0] },
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) },
{ offset: 0, color: withAlpha(c.series[0]!, 0.42) },
{ offset: 1, color: withAlpha(c.series[0]!, 0.01) },
],
},
},
@@ -78,8 +78,8 @@ export function observationsAreaOption(
smooth: true,
symbol: "none",
data: points.map((p) => [p.hour, p.uniquePackets]),
lineStyle: { color: c.secondary, width: 1.3, type: "dashed" },
itemStyle: { color: c.secondary },
lineStyle: { color: c.series[2], width: 1.3, type: "dashed" },
itemStyle: { color: c.series[2] },
},
],
};
@@ -198,10 +198,10 @@ export function presetBarsOption(
},
},
series: [
{ name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.primary) },
{ name: labels.nodes, ...segment(rows.map((r) => r.nodes), c.series[0]!) },
{
name: labels.observers,
...segment(rows.map((r) => r.observers), c.secondary),
...segment(rows.map((r) => r.observers), c.series[2]!),
// outer segment carries the row total so it sits at the end of the whole stack
label: {
show: true,
@@ -327,8 +327,8 @@ export function airtimeOption(points: TelemetryPoint[], c: ChartColors, bucketMs
xAxis: timeAxis(c),
yAxis: percentAxis(c),
series: [
{ name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), 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: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.primary }, areaStyle: { color: withAlpha(c.primary, 0.35) }, itemStyle: { color: c.primary } },
{ name: "RX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeRxSecs", bucketMs), lineStyle: { width: 1, color: c.series[1] }, areaStyle: { color: withAlpha(c.series[1]!, 0.35) }, itemStyle: { color: c.series[1] } },
{ name: "TX", type: "line", stack: "air", smooth: true, symbol: "none", connectNulls: true, data: airtimePctSeries(points, "airtimeTxSecs", bucketMs), lineStyle: { width: 1, color: c.series[0] }, areaStyle: { color: withAlpha(c.series[0]!, 0.35) }, itemStyle: { color: c.series[0] } },
],
};
}
+129 -8
View File
@@ -31,6 +31,8 @@ function readVar(name: string): string {
}
type RGB = [number, number, number];
type OKLab = [number, number, number];
type OKLCH = [number, number, number];
function parseColor(c: string): RGB {
const s = c.trim();
@@ -48,6 +50,125 @@ function parseColor(c: string): RGB {
return [128, 128, 128];
}
function srgbToLinear(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}
function linearToSrgb(channel: number): number {
const c = channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055;
return c * 255;
}
// OKLab distances track perceived difference, which is what tells two series apart.
function rgbToOklab(color: string): OKLab {
const [r8, g8, b8] = parseColor(color);
const r = srgbToLinear(r8), g = srgbToLinear(g8), b = srgbToLinear(b8);
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
return [
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
];
}
function oklabToRgbRaw([L, a, b]: OKLab): RGB {
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
return [
linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
];
}
function labToLch([L, a, b]: OKLab): OKLCH {
return [L, Math.hypot(a, b), (Math.atan2(b, a) * 180 / Math.PI + 360) % 360];
}
function lchToLab([L, C, h]: OKLCH): OKLab {
const radians = h * Math.PI / 180;
return [L, C * Math.cos(radians), C * Math.sin(radians)];
}
function inGamut(rgb: RGB): boolean {
return rgb.every((channel) => channel >= 0 && channel <= 255);
}
// Out-of-gamut colours lose chroma rather than having channels clipped, so hue and lightness hold.
function lchToColor(lch: OKLCH): string {
let candidate = lch;
if (!inGamut(oklabToRgbRaw(lchToLab(candidate)))) {
let low = 0, high = lch[1];
for (let i = 0; i < 14; i += 1) {
const chroma = (low + high) / 2;
if (inGamut(oklabToRgbRaw(lchToLab([lch[0], chroma, lch[2]])))) low = chroma;
else high = chroma;
}
candidate = [lch[0], low, lch[2]];
}
const rgb = oklabToRgbRaw(lchToLab(candidate)).map((channel) => Math.round(Math.max(0, Math.min(255, channel)))) as RGB;
return `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})`;
}
function relativeLuminance(color: string): number {
const [r, g, b] = parseColor(color).map(srgbToLinear);
return 0.2126 * r! + 0.7152 * g! + 0.0722 * b!;
}
export function colorContrast(a: string, b: string): number {
const l1 = relativeLuminance(a), l2 = relativeLuminance(b);
return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
}
export function colorDistance(a: string, b: string): number {
const x = rgbToOklab(a), y = rgbToOklab(b);
return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]);
}
function contrastingColor(lch: OKLCH, background: string): string {
let color = lchToColor(lch);
if (colorContrast(color, background) >= 3) return color;
const lighter = relativeLuminance(background) < 0.4;
let lightness = lch[0];
for (let i = 0; i < 24; i += 1) {
lightness = Math.max(0.05, Math.min(0.95, lightness + (lighter ? 0.025 : -0.025)));
color = lchToColor([lightness, lch[1], lch[2]]);
if (colorContrast(color, background) >= 3) break;
}
return color;
}
// Below this OKLab distance two series read as one colour (Phosphor's primary and green are identical).
const MIN_SERIES_DISTANCE = 0.05;
// Keeps each colour unless it repeats an earlier one; a repeat becomes the hue furthest from the rest.
export function distinctColors(colors: string[], background: string): string[] {
const out: string[] = [];
colors.forEach((color, i) => {
if (out.every((kept) => colorDistance(color, kept) >= MIN_SERIES_DISTANCE)) {
out.push(color);
return;
}
const [lightness, chroma, hue] = labToLch(rgbToOklab(color));
const others = [...out, ...colors.slice(i + 1)];
let best = color, bestScore = -1;
for (let step = 1; step < 36; step += 1) {
const candidate = contrastingColor([lightness, Math.max(0.08, chroma), (hue + step * 137.507764) % 360], background);
const score = Math.min(...others.map((other) => colorDistance(candidate, other)));
if (score > bestScore) {
best = candidate;
bestScore = score;
}
}
out.push(best);
});
return out;
}
export function withAlpha(color: string, a: number): string {
const [r, g, b] = parseColor(color);
return `rgba(${r}, ${g}, ${b}, ${a})`;
@@ -78,8 +199,8 @@ export function readChartColors(): ChartColors {
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 = [
// token order matters: series[0..5] stand in for primary, green, secondary, warn, danger, primaryDim
const series = distinctColors([
c.primary,
c.green,
c.secondary,
@@ -88,7 +209,7 @@ export function readChartColors(): ChartColors {
c.primaryDim,
blend(c.primary, c.secondary),
blend(c.green, c.warn),
];
], c.bgSurface);
return { ...c, series };
}
@@ -104,11 +225,11 @@ export function useChartColors(): ChartColors {
// views stay in sync. Unknown types fall back to a dim primary.
export 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;
case "companion": return c.series[0]!;
case "repeater": return c.series[1]!;
case "room_server": return c.series[2]!;
case "sensor": return c.series[3]!;
default: return c.series[5]!;
}
}
+1 -1
View File
@@ -89,7 +89,7 @@ export function trafficHeatmapOption(model: TrafficModel, c: ChartColors, t: TFu
yAxis: { type: "category", data: model.days, inverse: true, axisTick: { show: false }, axisLine: { show: false },
axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 9, formatter: (day: string) => day.slice(5) } },
visualMap: { min: 0, max: Math.max(1, model.peak?.total ?? 0), calculable: false, orient: "horizontal", left: "center", bottom: 0, itemWidth: 10, itemHeight: 130,
text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [c.primaryDim, c.primary, c.secondary, c.green, c.warn] } },
text: [t("traffic.more"), t("traffic.fewer")], textStyle: { color: c.textMuted, fontSize: 10 }, inRange: { color: [5, 0, 2, 1, 3].map((i) => c.series[i]!) } },
series: [{ name: t("traffic.receptionsColumn"), type: "heatmap", data: model.heatmap, itemStyle: { borderColor: c.bgSurface, borderWidth: 1 }, emphasis: { itemStyle: { borderColor: c.textBright, borderWidth: 1 } } }],
};
}
+2
View File
@@ -77,6 +77,8 @@ export interface TopObserver {
observationCount: number;
}
export type AdvertiserSort = "flood" | "direct";
export interface TopAdvertiser {
nodeId: string | null; // null once the node row is deleted; key rows by publicKey
publicKey: string;
+4 -4
View File
@@ -14,7 +14,7 @@ import {
getClockDrift,
type StatsRegion,
} from "../../api/client";
import { RANGE_MS, type StatsRange } from "./types";
import { RANGE_MS, type AdvertiserSort, type StatsRange } from "./types";
// Shared query options: cache for 30s; previous data stays as placeholder so tabs can decide whether to show it during a switch.
const common = {
@@ -102,11 +102,11 @@ export function useTopObservers(range: StatsRange, limit = 10) {
});
}
export function useTopAdvertisers(range: StatsRange, limit = 10) {
export function useTopAdvertisers(range: StatsRange, sort: AdvertiserSort, limit = 10) {
const { where, regionKey, isResolved } = useStatsRegion();
return useQuery({
queryKey: ["stats-top-advertisers", regionKey, range, limit],
...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), limit)),
queryKey: ["stats-top-advertisers", regionKey, range, sort, limit],
...gatedOn(isResolved, () => getTopAdvertisers(where, sinceFor(range), sort, limit)),
...common,
});
}
+37 -2
View File
@@ -283,7 +283,42 @@
"noRecentPackets": "No recent packets",
"observations_one": "{{formatted}} obs",
"observations_other": "{{formatted}} obs",
"hops": "Hops"
"hops": "Hops",
"hardware": "Hardware",
"power": "Power",
"antenna": "Antenna",
"powerSource": "Power source",
"powerSources": {
"mains": "Mains",
"solar": "Solar",
"battery": "Battery",
"poe": "PoE"
},
"height": "Height",
"heightAgl": "{{value}} m AGL",
"heightAsl": "{{value}} m ASL",
"admins": "Admins",
"mmDefaultScope": "Default scope",
"scopeState": {
"unknown": "Unknown",
"none": "None"
},
"scopeSource": {
"advert": "advert",
"reported": "reported"
},
"confirmedScopes": "Confirmed scopes",
"noConfirmedScopes": "None confirmed",
"observedPackets_one": "observed {{count}} packet",
"observedPackets_other": "observed {{count}} packets",
"observed": "observed",
"reported": "reported",
"uploaded": "uploaded",
"meshmapper": "MeshMapper",
"fromMeshMapper": "From MeshMapper",
"updated": "updated",
"carpeater": "CARpeater",
"carpeaterHint": "Mobile repeater in a vehicle: its position moves."
},
"nodes": {
"lastHeard": "last heard {{ago}}",
@@ -816,7 +851,7 @@
"flood": "Flood",
"direct": "Direct",
"topAdvertisers": "Top advertisers · {{range}}",
"floodDirect": "flood · direct",
"sortBy": "Sort advertisers by",
"noAdvertisers": "No advertisers",
"topTalkers": "Top talkers · {{range}}",
"byName": "by name"
+1 -1
View File
@@ -848,7 +848,7 @@
"flood": "Flood",
"direct": "Direct",
"topAdvertisers": "Principaux annonceurs · {{range}}",
"floodDirect": "flood · direct",
"sortBy": "Trier les annonceurs par",
"noAdvertisers": "Aucun annonceur",
"topTalkers": "Principaux émetteurs · {{range}}",
"byName": "par nom"
+1 -1
View File
@@ -815,7 +815,7 @@
"flood": "Flood",
"direct": "Direkt",
"topAdvertisers": "Mest aktiva annonsörer · {{range}}",
"floodDirect": "flood · direkt",
"sortBy": "Sortera annonsörer efter",
"noAdvertisers": "Inga annonsörer",
"topTalkers": "Mest aktiva sändare · {{range}}",
"byName": "efter namn"
+3 -2
View File
@@ -431,15 +431,16 @@ describe("stats endpoints", () => {
expect(url.searchParams.get("limit")).toBe("15");
});
it("hits /stats/top-advertisers with iatas/since/limit", async () => {
it("hits /stats/top-advertisers with iatas/since/sort/limit", async () => {
const getUrl = mockFetchOnce([]);
await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, 10);
await getTopAdvertisers(["YOW", "YYZ"], 1700000000000, "direct", 10);
const url = new URL(getUrl());
expect(url.pathname).toContain("/stats/top-advertisers");
expect(url.searchParams.get("iatas")).toBe("YOW,YYZ");
expect(url.searchParams.get("since")).toBe("1700000000000");
expect(url.searchParams.get("sort")).toBe("direct");
expect(url.searchParams.get("limit")).toBe("10");
});
+16
View File
@@ -72,3 +72,19 @@ describe("Tooltip (mobile)", () => {
expect(onParentClick).not.toHaveBeenCalled();
});
});
describe("Tooltip focusable", () => {
it("is a tab stop described by its label for keyboard and screen-reader users", () => {
setMobile(false);
render(<Tooltip label="tip text" focusable><span>target</span></Tooltip>);
const trigger = screen.getByText("target").parentElement!;
expect(trigger).toHaveAttribute("tabindex", "0");
expect(trigger).toHaveAccessibleDescription("tip text");
});
it("is not a tab stop by default", () => {
setMobile(false);
render(<Tooltip label="tip text"><span>target</span></Tooltip>);
expect(screen.getByText("target").parentElement).not.toHaveAttribute("tabindex");
});
});
+117 -3
View File
@@ -5,7 +5,7 @@ import type { ReactNode } from "react";
import { NodeDetailPanel } from "../../../src/features/nodes/NodeDetailPanel";
import i18n from "../../../src/i18n";
import { getNode, getNodeObservations, getNodeNeighbors } from "../../../src/api/client";
import type { Node, NodeNeighbor } from "../../../src/features/nodes/types";
import type { MeshMapperRepeater, Node, NodeNeighbor } from "../../../src/features/nodes/types";
vi.mock("../../../src/api/client", () => ({
getNode: vi.fn(),
@@ -45,8 +45,8 @@ function renderPanel(onViewNode = vi.fn()) {
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
render(<NodeDetailPanel nodeId="node-self" onClose={vi.fn()} onViewObserver={vi.fn()} onViewNode={onViewNode} />, { wrapper });
return { onViewNode };
const result = render(<NodeDetailPanel nodeId="node-self" onClose={vi.fn()} onViewObserver={vi.fn()} onViewNode={onViewNode} />, { wrapper });
return { ...result, onViewNode };
}
beforeEach(() => {
@@ -227,3 +227,117 @@ describe("NodeDetailPanel in French", () => {
expect(await screen.findByText("Possiblement étranger")).toBeInTheDocument();
});
});
describe("NodeDetailPanel MeshMapper", () => {
const meshmapper: MeshMapperRepeater = {
iata: "YOW",
fetchedAt: Date.now() - 60_000,
site: {
power: "1W", hardware: "RAK4631", antenna: "5.8 dBi fibreglass",
heightM: 12, heightAslM: 341.6, powerSource: "solar",
notes: "On the fire tower, east face",
},
carpeater: false,
admins: ["MrAlders0n"],
scopes: [
{ scope: "yow", observedAt: Date.now() - 3_600_000, observedPackets: 37, proof: "multibyte", reportedAt: Date.now() - 7_200_000 },
],
scopesCheckedAt: Date.now() - 7_200_000,
defaultScope: { name: "#yow", state: "named", updatedAt: Date.now() - 180_000, source: "advert" },
};
const empty: MeshMapperRepeater = { fetchedAt: 1, site: {}, carpeater: false, admins: [], scopes: [] };
it("shows MeshMapper's site details, admins and scopes", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper });
renderPanel();
expect(await screen.findByText("MeshMapper")).toBeInTheDocument();
expect(screen.getByText("RAK4631")).toBeInTheDocument();
expect(screen.getByText("1W")).toBeInTheDocument();
expect(screen.getByText("5.8 dBi fibreglass")).toBeInTheDocument();
expect(screen.getByText("Solar")).toBeInTheDocument();
expect(screen.getByText("12 m AGL · 341.6 m ASL")).toBeInTheDocument();
expect(screen.getByText("MrAlders0n")).toBeInTheDocument();
expect(screen.getByText("#yow")).toBeInTheDocument();
expect(screen.getByText("yow")).toBeInTheDocument();
expect(screen.getByText("observed 37 packets · reported")).toBeInTheDocument();
expect(screen.getByText("On the fire tower, east face")).toBeInTheDocument();
expect(screen.getByText(/From MeshMapper/)).toBeInTheDocument();
expect(screen.queryByText("CARpeater")).not.toBeInTheDocument();
});
it("never shows MeshMapper's proof type", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", observedPackets: 3, proof: "floor" }] } });
renderPanel();
expect(await screen.findByText("observed 3 packets")).toBeInTheDocument();
expect(screen.queryByText(/floor/)).not.toBeInTheDocument();
});
it("describes a scope confirmed only by report or upload", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopes: [{ scope: "yow", reportedAt: 1, uploadedAt: 2 }] } });
renderPanel();
expect(await screen.findByText("reported · uploaded")).toBeInTheDocument();
});
it("shows height above ground alone when sea level is missing", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12 } } });
renderPanel();
expect(await screen.findByText("12 m AGL")).toBeInTheDocument();
});
it("formats heights in the reader's locale", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, site: { heightM: 12.25, heightAslM: 341.6 } } });
await act(() => i18n.changeLanguage("fr"));
renderPanel();
expect(await screen.findByText("12,3 m AGL · 341,6 m ASL")).toBeInTheDocument();
await act(() => i18n.changeLanguage("en"));
});
it("is hidden when the node has no MeshMapper record", async () => {
renderPanel();
await screen.findByText("Self Node");
expect(screen.queryByText("MeshMapper")).not.toBeInTheDocument();
expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument();
});
it.each([
["nothing set", empty],
["only a CARpeater flag", { ...empty, carpeater: true }],
["only a sea-level height", { ...empty, site: { heightAslM: 341.6 } }],
])("is hidden when the record has %s", async (_, mm) => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: mm });
renderPanel();
await screen.findByText("Self Node");
expect(screen.queryByText(/From MeshMapper/)).not.toBeInTheDocument();
});
it("shows an unknown default scope and an answered-but-empty scope list", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, scopesCheckedAt: 1, defaultScope: { state: "unknown" } } });
renderPanel();
expect(await screen.findByText("Unknown")).toBeInTheDocument();
expect(screen.getByText("None confirmed")).toBeInTheDocument();
});
it("shows a default scope of none", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...empty, defaultScope: { state: "none" } } });
renderPanel();
expect(await screen.findByText("None")).toBeInTheDocument();
});
it("badges a CARpeater next to the name, reachable by keyboard", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, carpeater: true } });
renderPanel();
const badge = await screen.findByText("CARpeater");
expect(badge.className).not.toContain("uppercase");
const trigger = badge.parentElement!;
expect(trigger).toHaveAttribute("tabindex", "0");
expect(trigger).toHaveAccessibleDescription("Mobile repeater in a vehicle: its position moves.");
});
it("renders admin-entered markup as plain text", async () => {
mockGetNode.mockResolvedValue({ ...node, meshmapper: { ...meshmapper, site: { notes: "<b>bold</b>" } } });
const { container } = renderPanel();
expect(await screen.findByText("<b>bold</b>")).toBeInTheDocument();
expect(container.querySelector("b")).toBeNull();
});
});
+18 -4
View File
@@ -1,5 +1,5 @@
import { afterEach, beforeEach, expect, it, onTestFinished, vi } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TalkersTab } from "../../../src/features/stats/TalkersTab";
import { getTopAdvertisers, getTopTalkers } from "../../../src/api/client";
@@ -59,7 +59,8 @@ it.each(["region", "range"] as const)("hides previous results while the new %s i
await screen.findByText("New sender");
const request = vi.mocked(getTopAdvertisers).mock.calls[1]!;
expect(request[0]).toEqual(filter === "region" ? ["YOW"] : ["YVR"]);
expect(request[2]).toBe(20);
expect(request[2]).toBe("flood");
expect(request[3]).toBe(20);
const windowMs = (filter === "range" ? 7 : 1) * 86_400_000;
expect(request[1]).toBeGreaterThanOrEqual(requestedAt - windowMs);
expect(request[1]).toBeLessThanOrEqual(Date.now() - windowMs);
@@ -73,7 +74,7 @@ it("shows an advertiser refetch error instead of cached rows, keeps the sender p
await screen.findByText("Failed to load");
expect(screen.queryByText("Old advertiser")).not.toBeInTheDocument();
expect(screen.getByText("Old sender")).toBeInTheDocument();
expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", 20])).toEqual(advertisers);
expect(client.getQueryData(["stats-top-advertisers", "YVR", "24h", "flood", 20])).toEqual(advertisers);
vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Recovered advertiser" }]);
await act(() => client.refetchQueries({ queryKey: ["stats-top-advertisers"] }));
await screen.findByText("Recovered advertiser");
@@ -103,7 +104,7 @@ it("retains valid rows and rates during an ordinary background refresh", async (
vi.mocked(getTopAdvertisers).mockReturnValue(next.promise);
let refresh!: Promise<void>;
await act(async () => { refresh = client.refetchQueries({ queryKey: ["stats-top-advertisers"] }); });
expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", 20])?.fetchStatus).toBe("fetching");
expect(client.getQueryState(["stats-top-advertisers", "YVR", "24h", "flood", 20])?.fetchStatus).toBe("fetching");
expect(screen.getByText("Old advertiser")).toBeInTheDocument();
expect(screen.getByText("10/d")).toBeInTheDocument();
expect(screen.getByText("4/d")).toBeInTheDocument();
@@ -158,3 +159,16 @@ it("divides advert rates by the hours the rollup actually covers", async () => {
expect(await screen.findByText("21/d")).toBeInTheDocument();
expect(screen.getByText("4.2/d")).toBeInTheDocument();
});
it("asks the server for flood-ranked advertisers by default and refetches direct-ranked from the toggle", async () => {
mount();
await screen.findByText("Old advertiser");
expect(vi.mocked(getTopAdvertisers).mock.calls[0]!.slice(2)).toEqual(["flood", 20]);
const sort = screen.getByRole("group", { name: "Sort advertisers by" });
expect(within(sort).getByRole("button", { name: "Flood" })).toHaveAttribute("aria-pressed", "true");
vi.mocked(getTopAdvertisers).mockResolvedValue([{ ...advertisers[0]!, nodeName: "Direct advertiser" }]);
fireEvent.click(within(sort).getByRole("button", { name: "Direct" }));
await screen.findByText("Direct advertiser");
expect(vi.mocked(getTopAdvertisers).mock.calls[1]!.slice(2)).toEqual(["direct", 20]);
expect(within(sort).getByRole("button", { name: "Direct" })).toHaveAttribute("aria-pressed", "true");
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { blend, colorContrast, colorDistance, distinctColors } from "../../../src/features/stats/chartTheme";
interface ThemeFixture {
id: string;
vars: Record<string, string>;
}
const themes = JSON.parse(readFileSync("public/themes.json", "utf8")) as ThemeFixture[];
function seriesFor(theme: ThemeFixture) {
const v = (token: string) => theme.vars[`--palette-${token}`]!;
return [v("primary"), v("green"), v("secondary"), v("warn"), v("danger"), v("primary-dim"), blend(v("primary"), v("secondary")), blend(v("green"), v("warn"))];
}
describe("distinctColors", () => {
it("keeps a theme's colours when none repeat", () => {
const colors = ["#3B82F6", "#22C55E", "#A78BFA", "#EAB308"];
expect(distinctColors(colors, "#111114")).toEqual(colors);
});
it("replaces a repeated colour with a distinct, visible one", () => {
// Phosphor uses the same green for primary and success
const [primary, green] = distinctColors(["#4ADE80", "#4ADE80", "#22D3EE"], "#0A1628");
expect(primary).toBe("#4ADE80");
expect(colorDistance(green!, "#4ADE80")).toBeGreaterThan(0.1);
expect(colorDistance(green!, "#22D3EE")).toBeGreaterThan(0.1);
expect(colorContrast(green!, "#0A1628")).toBeGreaterThanOrEqual(3);
});
it("gives every bundled theme eight separable series and leaves clash-free themes untouched", () => {
for (const theme of themes) {
const wanted = seriesFor(theme);
const series = distinctColors(wanted, theme.vars["--palette-bg-surface"]!);
const pairs = series.flatMap((color, i) => series.slice(i + 1).map((other) => colorDistance(color, other)));
expect(series, theme.id).toHaveLength(8);
expect(Math.min(...pairs), theme.id).toBeGreaterThanOrEqual(0.05);
if (theme.id === "neutral-blue") expect(series).toEqual(wanted);
}
});
});
+1 -1
View File
@@ -22,7 +22,7 @@ const hooks = [
["usePayloadBreakdown", () => usePayloadBreakdown("24h"), client.getPayloadBreakdown],
["useTopNodes", () => useTopNodes("24h"), client.getTopNodes],
["useTopObservers", () => useTopObservers("24h"), client.getTopObservers],
["useTopAdvertisers", () => useTopAdvertisers("24h"), client.getTopAdvertisers],
["useTopAdvertisers", () => useTopAdvertisers("24h", "flood"), client.getTopAdvertisers],
["useTopTalkers", () => useTopTalkers("24h"), client.getTopTalkers],
["useRadioPresets", () => useRadioPresets(), client.getRadioPresets],
["useNodeTypes", () => useNodeTypes(), client.getStatsNodeTypes],