mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 18:51:21 +00:00
Sync dev1 Atlas with current upstream web and paired backend APIs
This commit is contained in:
commit
6f94e88540
30 files changed
+583
-82
No files matched your search
Generated
+5
-5
@@ -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
@@ -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
@@ -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[]> {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,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>}
|
||||
</>}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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] } },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } } }],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
|
||||
Reference in new issue
Block a user