mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 15:42:38 +00:00
Merge remote-tracking branch 'origin/codex/atlas-observers-20261014' into codex/sync-dev1-web-20261015
This commit is contained in:
commit
b21581cb21
19 files changed
+182
-55
No files matched your search
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "beacon-web",
|
||||
"version": "2.0.5-dev1.1",
|
||||
"version": "2.1.0-dev1.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "beacon-web",
|
||||
"version": "2.0.5-dev1.1",
|
||||
"version": "2.1.0-dev1.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@nazka/map-gl-js-spiderfy": "^2.0.0",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "beacon-web",
|
||||
"private": true,
|
||||
"version": "2.0.5-dev1.1",
|
||||
"version": "2.1.0-dev1.4",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+6
-5
@@ -267,8 +267,8 @@ export function getTraceDetail(tag: string): Promise<TraceDetail> {
|
||||
return request(`/traces/${tag}`);
|
||||
}
|
||||
|
||||
export async function getObserver(observerId: string): Promise<Observer> {
|
||||
const observer = await request<Observer>(`/observers/${observerId}`);
|
||||
export async function getObserver(observerId: string, signal?: AbortSignal): Promise<Observer> {
|
||||
const observer = await request<Observer>(`/observers/${observerId}`, undefined, signal);
|
||||
// Servers before json.RawMessage (and cache entries written by them) send the stored JSON as base64.
|
||||
let metadata: unknown = observer.statusMetadata;
|
||||
if (typeof metadata === "string") {
|
||||
@@ -457,12 +457,13 @@ export function getObserverTelemetry(
|
||||
range: string,
|
||||
interval?: string,
|
||||
afterId?: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ObserverTelemetry> {
|
||||
return request(`/observers/${observerId}/telemetry`, { range, interval, afterId });
|
||||
return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }, signal);
|
||||
}
|
||||
|
||||
export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise<ObserverActivity> {
|
||||
return request(`/observers/${observerId}/activity`, { range, interval, until });
|
||||
export function getObserverActivity(observerId: string, range: string, interval: string, until?: number, signal?: AbortSignal): Promise<ObserverActivity> {
|
||||
return request(`/observers/${observerId}/activity`, { range, interval, until }, signal);
|
||||
}
|
||||
|
||||
// Lets a caller hide a feature the server doesn't have rather than show it as failed.
|
||||
|
||||
@@ -146,7 +146,7 @@ export function AtlasCard({
|
||||
);
|
||||
|
||||
const actions = (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
<div className="ml-auto flex max-w-full flex-wrap items-center justify-end gap-1">
|
||||
{views.length > 1 && (
|
||||
<Segmented
|
||||
size="xs"
|
||||
@@ -294,7 +294,7 @@ export function AtlasCard({
|
||||
accent={c.series[0]!}
|
||||
spark={obs.sample.counts}
|
||||
markPeak
|
||||
sublabel={t(`atlas.ranges.${range}`)}
|
||||
sublabel={obs.sample.partial ? t("atlas.observerPartialCoverage") : t(`atlas.ranges.${range}`)}
|
||||
/>
|
||||
<StatCard
|
||||
compact
|
||||
@@ -302,18 +302,20 @@ export function AtlasCard({
|
||||
value={obs.sample.snr === null ? dash : `${obs.sample.snr.toFixed(1)} dB`}
|
||||
accent={c.series[2]!}
|
||||
spark={obs.sample.snrHourly}
|
||||
interpolateGaps
|
||||
/>
|
||||
<StatCard
|
||||
compact
|
||||
label={t("atlas.noiseFloor")}
|
||||
value={obs.sample.noise === null ? dash : `${Math.round(obs.sample.noise)} dB`}
|
||||
value={obs.sample.noise === null ? dash : `${Math.round(obs.sample.noise)} dBm`}
|
||||
accent={c.series[1]!}
|
||||
spark={obs.sample.noiseHourly}
|
||||
interpolateGaps
|
||||
/>
|
||||
{obs.sample.battery !== null ? (
|
||||
<StatCard compact label={t("atlas.battery")} value={formatBattery(obs.sample.battery)} accent={c.series[3]!} spark={obs.sample.batteryHourly} />
|
||||
<StatCard compact label={t("atlas.battery")} value={formatBattery(obs.sample.battery)} accent={c.series[3]!} spark={obs.sample.batteryHourly} interpolateGaps />
|
||||
) : (
|
||||
<StatCard compact label={t("atlas.uptime")} value={obs.sample.uptime === null ? dash : formatUptime(obs.sample.uptime)} accent={c.series[3]!} />
|
||||
<StatCard compact label={t("atlas.uptime")} value={obs.sample.uptime === null ? dash : formatUptime(obs.sample.uptime)} accent={c.series[3]!} spark={obs.sample.uptimeHourly} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[11px] text-text-muted">
|
||||
@@ -324,6 +326,7 @@ export function AtlasCard({
|
||||
</span>
|
||||
)}
|
||||
{radio && <span>{radio}</span>}
|
||||
{obs.sample.battery !== null && obs.sample.uptime !== null && <span>{t("atlas.uptime")} {formatUptime(obs.sample.uptime)}</span>}
|
||||
{obs.loading && <span role="status">{t("common.loading")}</span>}
|
||||
{obs.failed && <span className="text-danger">{t("common.loadFailed")}</span>}
|
||||
<span className="whitespace-nowrap">
|
||||
|
||||
@@ -169,7 +169,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="relative min-h-0 flex-1 overflow-y-auto">
|
||||
<div className="mx-auto flex w-full min-w-0 max-w-[1600px] flex-col gap-3.5 p-4">
|
||||
{typed.length > 0 && (
|
||||
<Card title={t("atlas.matches")}>
|
||||
|
||||
@@ -2,7 +2,8 @@ import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from
|
||||
import type { Node, NodeObservation, NodeSummary } from "../nodes/types";
|
||||
import type { Observer } from "../observers/types";
|
||||
|
||||
export const ATLAS_KEY = "beacon-my-atlas-v1";
|
||||
export const ATLAS_KEY = "beacon-my-atlas-v2";
|
||||
export const ATLAS_LEGACY_KEY = "beacon-my-atlas-v1";
|
||||
export const ATLAS_LIMIT = 12;
|
||||
export const REPORT_LIMIT = 200;
|
||||
export type AtlasRange = "24h" | "3d";
|
||||
|
||||
@@ -11,6 +11,7 @@ const HOUR = 3_600_000;
|
||||
|
||||
export interface ObserverSample {
|
||||
heard: number | null;
|
||||
partial?: boolean;
|
||||
counts: (number | null)[];
|
||||
snrHourly: (number | null)[];
|
||||
snr: number | null;
|
||||
@@ -19,6 +20,7 @@ export interface ObserverSample {
|
||||
batteryHourly: (number | null)[];
|
||||
battery: number | null;
|
||||
uptime: number | null;
|
||||
uptimeHourly: (number | null)[];
|
||||
}
|
||||
|
||||
export function summarizeObserver(
|
||||
@@ -29,26 +31,31 @@ export function summarizeObserver(
|
||||
now: number,
|
||||
): ObserverSample {
|
||||
const hours = RANGE_HOURS[range];
|
||||
const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
||||
// Slots count back from the tick, newest last; a bucket newer than the tick lands in the newest slot.
|
||||
const age = (t: number) => Math.max(0, Math.floor((now - t) / HOUR));
|
||||
const bin = (t: number) => hours - 1 - age(t);
|
||||
const inWindow = (t: number) => Number.isFinite(t) && age(t) < hours;
|
||||
const inWindow = (t: number) => Number.isFinite(t) && t <= now && age(t) < hours;
|
||||
|
||||
const counts = Array<number | null>(hours).fill(0);
|
||||
const counts = Array.from({ length: hours }, (_, i): number | null => {
|
||||
if (!activity) return null;
|
||||
const hour = Math.floor((now - (hours - 1 - i) * HOUR) / HOUR) * HOUR;
|
||||
return activity.rawFrom != null && hour < activity.rawFrom && (activity.rolledUntil == null || hour >= activity.rolledUntil) ? null : 0;
|
||||
});
|
||||
const snrHourly = Array<number | null>(hours).fill(null);
|
||||
let heard: number | null = null;
|
||||
let heard: number | null = counts.some(finite) ? 0 : null;
|
||||
let weighted = 0;
|
||||
let weight = 0;
|
||||
for (const p of activity?.points ?? []) {
|
||||
if (!inWindow(p.t)) continue;
|
||||
const i = bin(p.t);
|
||||
if (p.observations === null) {
|
||||
if (!finite(p.observations)) {
|
||||
counts[i] = null;
|
||||
continue;
|
||||
}
|
||||
counts[i] = (counts[i] ?? 0) + p.observations;
|
||||
heard = (heard ?? 0) + p.observations;
|
||||
if (p.snrAvg !== null && p.observations > 0) {
|
||||
if (finite(p.snrAvg) && p.observations > 0) {
|
||||
snrHourly[i] = p.snrAvg;
|
||||
weighted += p.snrAvg * p.observations;
|
||||
weight += p.observations;
|
||||
@@ -59,23 +66,26 @@ export function summarizeObserver(
|
||||
const live = (telemetry?.points ?? []).filter((p) => inWindow(p.t) && hasTelemetry([p])).sort((a, b) => a.t - b.t);
|
||||
const series = (key: keyof TelemetryPoint) => {
|
||||
const out = Array<number | null>(hours).fill(null);
|
||||
for (const p of live) if (p[key] !== null) out[bin(p.t)] = p[key];
|
||||
for (const p of live) if (finite(p[key])) out[bin(p.t)] = p[key];
|
||||
return out;
|
||||
};
|
||||
const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => p[key] !== null)?.[key];
|
||||
const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => finite(p[key]))?.[key];
|
||||
// Mains-powered observers report 0 mV; a battery never reads zero, so that is "no battery".
|
||||
const volts = (mv: number | null | undefined) => (mv ? mv / 1000 : null);
|
||||
const uptime = observer?.uptimeSeconds ?? latest("uptimeSeconds");
|
||||
const volts = (mv: number | null | undefined) => (finite(mv) && mv > 0 ? mv / 1000 : null);
|
||||
const battery = observer?.batteryLevel == null ? volts(latest("batteryMv")) : finite(observer.batteryLevel) && observer.batteryLevel > 0 ? observer.batteryLevel : null;
|
||||
const uptime = finite(observer?.uptimeSeconds) ? observer.uptimeSeconds : latest("uptimeSeconds");
|
||||
|
||||
return {
|
||||
heard,
|
||||
partial: activity !== undefined && counts.some(value => value === null),
|
||||
counts,
|
||||
snrHourly,
|
||||
snr: weight ? weighted / weight : null,
|
||||
noiseHourly: series("noiseFloorDb"),
|
||||
noise: latest("noiseFloorDb") ?? null,
|
||||
batteryHourly: series("batteryMv").map(volts),
|
||||
battery: volts(latest("batteryMv")),
|
||||
battery,
|
||||
uptime: uptime ?? null,
|
||||
uptimeHourly: series("uptimeSeconds"),
|
||||
};
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export function useAtlasObserver(observerId: string | null, range: AtlasRange, a
|
||||
const observer = useQuery({
|
||||
...base,
|
||||
queryKey: ["atlas-observer", observerId],
|
||||
queryFn: () => getObserver(observerId!),
|
||||
queryFn: ({ signal }) => getObserver(observerId!, signal),
|
||||
enabled: active && !throttled && observerId !== null,
|
||||
refetchInterval: ATLAS_REFRESH_MS,
|
||||
});
|
||||
@@ -33,14 +33,14 @@ export function useAtlasObserver(observerId: string | null, range: AtlasRange, a
|
||||
const activity = useQuery({
|
||||
...base,
|
||||
queryKey: ["atlas-observer-activity", observerId, range],
|
||||
queryFn: () => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined),
|
||||
queryFn: ({ signal }) => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined, signal),
|
||||
enabled: series,
|
||||
refetchInterval: (query) => pollUnlessMissing(query.state.error),
|
||||
});
|
||||
const telemetry = useQuery({
|
||||
...base,
|
||||
queryKey: ["atlas-observer-telemetry", observerId, range],
|
||||
queryFn: () => getObserverTelemetry(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL),
|
||||
queryFn: ({ signal }) => getObserverTelemetry(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined, signal),
|
||||
enabled: series,
|
||||
refetchInterval: (query) => pollUnlessMissing(query.state.error),
|
||||
});
|
||||
@@ -51,7 +51,7 @@ export function useAtlasObserver(observerId: string | null, range: AtlasRange, a
|
||||
return {
|
||||
observer,
|
||||
sample,
|
||||
loading: open && (activity.isPending || telemetry.isPending),
|
||||
loading: open && (activity.isLoading || telemetry.isLoading),
|
||||
unsupported: isNotFound(activity.error) || isNotFound(telemetry.error),
|
||||
failed: (activity.isError && !isNotFound(activity.error)) || (telemetry.isError && !isNotFound(telemetry.error)),
|
||||
};
|
||||
|
||||
@@ -14,18 +14,24 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number)
|
||||
const cards = useRef(new Map<number, HTMLElement>());
|
||||
const [drag, setDrag] = useState<Drag | null>(null);
|
||||
const cleanup = useRef<() => void>(() => {});
|
||||
const active = useRef(false);
|
||||
|
||||
useEffect(() => () => cleanup.current(), []);
|
||||
|
||||
const start = useCallback((index: number, event: PointerEvent<HTMLButtonElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
if (event.button !== 0 || active.current) return;
|
||||
event.preventDefault();
|
||||
active.current = true;
|
||||
const pointerId = event.pointerId;
|
||||
const handle = event.currentTarget;
|
||||
handle.setPointerCapture?.(pointerId);
|
||||
const rects = Array.from({ length: count }, (_, i) => cards.current.get(i)?.getBoundingClientRect() ?? new DOMRect());
|
||||
const origin = { x: event.clientX, y: event.clientY };
|
||||
let current: Drag = { from: index, over: index, dx: 0, dy: 0, rects };
|
||||
setDrag(current);
|
||||
|
||||
const onMove = (e: globalThis.PointerEvent) => {
|
||||
if (e.pointerId !== pointerId) return;
|
||||
const hit = rects.findIndex((r) => e.clientX >= r.left && e.clientX < r.right && e.clientY >= r.top && e.clientY < r.bottom);
|
||||
current = { ...current, dx: e.clientX - origin.x, dy: e.clientY - origin.y, over: hit === -1 ? current.over : hit };
|
||||
setDrag(current);
|
||||
@@ -35,20 +41,27 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number)
|
||||
setDrag(null);
|
||||
if (commit && current.over !== current.from) onReorder(current.from, current.over);
|
||||
};
|
||||
const onUp = () => end(true);
|
||||
const onUp = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(true); };
|
||||
const onCancel = () => end(false);
|
||||
const onPointerCancel = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(false); };
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape") end(false);
|
||||
};
|
||||
window.addEventListener("pointermove", onMove);
|
||||
window.addEventListener("pointerup", onUp);
|
||||
window.addEventListener("pointercancel", onCancel);
|
||||
window.addEventListener("pointercancel", onPointerCancel);
|
||||
window.addEventListener("keydown", onKey);
|
||||
window.addEventListener("blur", onCancel);
|
||||
handle.addEventListener("lostpointercapture", onPointerCancel);
|
||||
cleanup.current = () => {
|
||||
window.removeEventListener("pointermove", onMove);
|
||||
window.removeEventListener("pointerup", onUp);
|
||||
window.removeEventListener("pointercancel", onCancel);
|
||||
window.removeEventListener("pointercancel", onPointerCancel);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
window.removeEventListener("blur", onCancel);
|
||||
handle.removeEventListener("lostpointercapture", onPointerCancel);
|
||||
if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId);
|
||||
active.current = false;
|
||||
cleanup.current = () => {};
|
||||
};
|
||||
}, [count, onReorder]);
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas";
|
||||
import { ATLAS_KEY, ATLAS_LEGACY_KEY, parseAtlas, type SavedAtlas } from "./atlas";
|
||||
|
||||
export function useSavedAtlas() {
|
||||
const [initial] = useState(() => {
|
||||
try {
|
||||
return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false };
|
||||
return { value: parseAtlas(localStorage.getItem(ATLAS_KEY) ?? localStorage.getItem(ATLAS_LEGACY_KEY)), failed: false };
|
||||
} catch {
|
||||
return { value: parseAtlas(null), failed: true };
|
||||
}
|
||||
|
||||
@@ -32,13 +32,13 @@ const SPARK_W = 120;
|
||||
const SPARK_H = 20;
|
||||
const sparkSpacer = <div className="mt-1.5 h-[20px]" />;
|
||||
|
||||
function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) {
|
||||
function Sparkline({ values, color, markPeak, interpolateGaps }: { values: (number | null)[]; color: string; markPeak?: boolean; interpolateGaps?: boolean }) {
|
||||
const known = values.filter((v): v is number => v !== null);
|
||||
if (values.length < 2 || known.length < 2) return sparkSpacer;
|
||||
if (!known.length) return sparkSpacer;
|
||||
const max = Math.max(...known);
|
||||
const min = Math.min(...known);
|
||||
const range = max - min || 1;
|
||||
const x = (i: number) => (i / (values.length - 1)) * SPARK_W;
|
||||
const x = (i: number) => values.length === 1 ? SPARK_W / 2 : (i / (values.length - 1)) * SPARK_W;
|
||||
const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2);
|
||||
// A null hour breaks the line, so an outage shows as a gap rather than a straight join.
|
||||
const runs: string[][] = [[]];
|
||||
@@ -46,12 +46,15 @@ function Sparkline({ values, color, markPeak }: { values: (number | null)[]; col
|
||||
if (v === null) runs.push([]);
|
||||
else runs[runs.length - 1]!.push(`${x(i)},${y(v)}`);
|
||||
});
|
||||
const samples = values.flatMap((v, i) => v == null ? [] : [{ v, i }]);
|
||||
const peak = values.indexOf(max);
|
||||
return (
|
||||
<svg width="100%" height={SPARK_H} viewBox={`0 0 ${SPARK_W} ${SPARK_H}`} preserveAspectRatio="none" className="mt-1.5 overflow-visible" aria-hidden>
|
||||
{runs.filter((run) => run.length > 1).map((run) => (
|
||||
<polyline key={run[0]} fill="none" stroke={color} strokeWidth="1.5" points={run.join(" ")} />
|
||||
))}
|
||||
{runs.filter(run => run.length === 1).map(run => { const [px, py] = run[0]!.split(","); return <line data-sample key={run[0]} x1={px} x2={px} y1={py} y2={py} stroke={color} strokeWidth="3" strokeLinecap="round" vectorEffect="non-scaling-stroke" />; })}
|
||||
{interpolateGaps && samples.slice(1).map((point, i) => point.i > samples[i]!.i + 1 ? <line data-gap key={point.i} x1={x(samples[i]!.i)} y1={y(samples[i]!.v)} x2={x(point.i)} y2={y(point.v)} stroke={color} strokeWidth="1" strokeDasharray="3 3" opacity="0.45" /> : null)}
|
||||
{markPeak && (
|
||||
// zero-length round-capped stroke: stays a circle under preserveAspectRatio="none"
|
||||
<line data-peak x1={x(peak)} x2={x(peak)} y1={y(max)} y2={y(max)} stroke={color} strokeWidth="5" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
|
||||
@@ -91,6 +94,7 @@ export function StatCard({
|
||||
presence,
|
||||
sublabel,
|
||||
compact,
|
||||
interpolateGaps,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
@@ -99,6 +103,7 @@ export function StatCard({
|
||||
markPeak?: boolean;
|
||||
presence?: boolean[];
|
||||
sublabel?: ReactNode;
|
||||
interpolateGaps?: boolean;
|
||||
compact?: boolean; // narrow tiles: value stays text-xl so "-77.2 dBm" never wraps
|
||||
}) {
|
||||
return (
|
||||
@@ -109,7 +114,7 @@ export function StatCard({
|
||||
</div>
|
||||
<div className={`mt-0.5 font-mono text-xl font-bold tabular-nums text-text-bright ${compact ? "" : "sm:text-2xl"}`}>{value}</div>
|
||||
<div className="mt-auto">
|
||||
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} /> : sparkSpacer}
|
||||
{presence ? <PresenceStrip hours={presence} color={accent} /> : spark ? <Sparkline values={spark} color={accent} markPeak={markPeak} interpolateGaps={interpolateGaps} /> : sparkSpacer}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -284,7 +284,7 @@
|
||||
"observations_one": "{{formatted}} obs",
|
||||
"observations_other": "{{formatted}} obs",
|
||||
"hops": "Hops",
|
||||
"hardware": "Hardware",
|
||||
"hardware": "Hardware",
|
||||
"power": "Power",
|
||||
"antenna": "Antenna",
|
||||
"powerSource": "Power source",
|
||||
@@ -1008,11 +1008,12 @@
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"lastStatus": "Last status",
|
||||
"observerHelp": "What this observer received and reported, over the selected window. Status rows with no readings are ignored.",
|
||||
"observerHelp": "What this observer received and reported, over the selected window. Status rows with no readings are ignored. Solid lines show readings; dashed lines connect missing hours as visual estimates, not measurements. Uptime gaps remain open.",
|
||||
"observerUnsupported": "This server does not provide observer activity or telemetry.",
|
||||
"observerGone": "This observer is no longer known to the server.",
|
||||
"dragToReorder": "Drag to reorder",
|
||||
"remove": "Remove"
|
||||
"remove": "Remove",
|
||||
"observerPartialCoverage": "Partial history"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1011,11 +1011,12 @@
|
||||
"online": "En ligne",
|
||||
"offline": "Hors ligne",
|
||||
"lastStatus": "Dernier état",
|
||||
"observerHelp": "Ce que cet observateur a reçu et signalé sur la période choisie. Les états sans mesures sont ignorés.",
|
||||
"observerHelp": "Ce que cet observateur a reçu et signalé sur la période choisie. Les états sans mesures sont ignorés. Les lignes pleines indiquent les mesures; les pointillés relient les heures manquantes à titre indicatif. Les interruptions de disponibilité restent visibles.",
|
||||
"observerUnsupported": "Ce serveur ne fournit pas l’activité ni la télémétrie des observateurs.",
|
||||
"observerGone": "Cet observateur n’est plus connu du serveur.",
|
||||
"dragToReorder": "Glisser pour réordonner",
|
||||
"remove": "Retirer"
|
||||
"remove": "Retirer",
|
||||
"observerPartialCoverage": "Historique partiel"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,7 +215,7 @@ describe("AtlasCard", () => {
|
||||
expect(await screen.findByText("Packets heard")).toBeInTheDocument();
|
||||
await screen.findByText("12");
|
||||
expect(screen.getByText("6.5 dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("-117 dB")).toBeInTheDocument();
|
||||
expect(screen.getByText("-117 dBm")).toBeInTheDocument();
|
||||
expect(screen.getByText("4.12V")).toBeInTheDocument();
|
||||
expect(screen.getByText("Online")).toBeInTheDocument();
|
||||
expect(screen.getByText("915 MHz · SF11 · 250 kHz").closest(".flex-wrap")).toBeInTheDocument();
|
||||
|
||||
@@ -2,7 +2,7 @@ import { beforeEach, afterEach, describe, it, expect, vi } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage";
|
||||
import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas";
|
||||
import { ATLAS_KEY, ATLAS_LEGACY_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas";
|
||||
import * as api from "../../../src/api/client";
|
||||
import { ApiError } from "../../../src/api/client";
|
||||
import type { Node } from "../../../src/features/nodes/types";
|
||||
@@ -62,6 +62,15 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("My Atlas", () => {
|
||||
it("reads legacy saved nodes and retains their original storage when saving the new model", async () => {
|
||||
const legacy = JSON.stringify({ version: 1, nodes: [pin], range: "24h" });
|
||||
localStorage.setItem(ATLAS_LEGACY_KEY, legacy);
|
||||
mount();
|
||||
await screen.findByRole("button", { name: "Renamed repeater" });
|
||||
fireEvent.click(screen.getByRole("button", { name: "3d" }));
|
||||
expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].publicKey).toBe(pin.publicKey);
|
||||
expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(legacy);
|
||||
});
|
||||
it("renders saved nodes as Signal-style cards and nothing else before a search", async () => {
|
||||
save();
|
||||
mount();
|
||||
|
||||
@@ -12,6 +12,31 @@ const tele = (points: Partial<ObserverTelemetry["points"][number]>[]) => ({ rang
|
||||
t: now, batteryMv: null, airtimeTxSecs: null, airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null, ...p })) }) as ObserverTelemetry;
|
||||
|
||||
describe("summarizeObserver", () => {
|
||||
it("does not fill missing endpoints or unread rollup hours with measured zeros", () => {
|
||||
expect(summarizeObserver(undefined, undefined, undefined, "24h", now).counts.every(v => v === null)).toBe(true);
|
||||
const sample = summarizeObserver({ ...activity([]), rawFrom: now - HOUR }, undefined, undefined, "24h", now);
|
||||
expect(sample.counts[0]).toBeNull();
|
||||
expect(sample.counts.at(-1)).toBe(0);
|
||||
expect(sample.partial).toBe(true);
|
||||
});
|
||||
|
||||
it("shows an available status battery without inventing history", () => {
|
||||
const sample = summarizeObserver(undefined, undefined, { batteryLevel: 4.17 } as Observer, "24h", now);
|
||||
expect(sample.battery).toBe(4.17);
|
||||
expect(sample.batteryHourly.every(v => v === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes future and non-finite readings while preserving a real zero SNR and uptime reset", () => {
|
||||
const sample = summarizeObserver(activity([point(-5, 999, 99), point(0, 2, 0)]), tele([
|
||||
{ t: now + HOUR, batteryMv: 99999, noiseFloorDb: -1 },
|
||||
{ t: now - HOUR, noiseFloorDb: -110, uptimeSeconds: 100 },
|
||||
{ t: now, noiseFloorDb: -110, batteryMv: NaN, uptimeSeconds: 0 },
|
||||
]), undefined, "24h", now);
|
||||
expect(sample.heard).toBe(2);
|
||||
expect(sample.snr).toBe(0);
|
||||
expect(sample.battery).toBeNull();
|
||||
expect(sample.uptimeHourly.slice(-2)).toEqual([100, 0]);
|
||||
});
|
||||
it("maps ranges to the server's duration strings", () => {
|
||||
expect(OBSERVER_RANGE).toEqual({ "24h": "24h", "3d": "72h" });
|
||||
});
|
||||
|
||||
@@ -36,6 +36,17 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("useAtlasObserver", () => {
|
||||
it("cancels all in-flight observer requests on unmount", async () => {
|
||||
vi.mocked(api.getObserver).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(api.getObserverActivity).mockImplementation(() => new Promise(() => {}));
|
||||
vi.mocked(api.getObserverTelemetry).mockImplementation(() => new Promise(() => {}));
|
||||
const view = renderHook(() => useAtlasObserver(id, "24h", true, true), { wrapper });
|
||||
await tick(10);
|
||||
const signals = [vi.mocked(api.getObserver).mock.calls[0]![1]!, vi.mocked(api.getObserverActivity).mock.calls[0]![4]!, vi.mocked(api.getObserverTelemetry).mock.calls[0]![4]!];
|
||||
expect(signals.every(signal => !signal.aborted)).toBe(true);
|
||||
view.unmount();
|
||||
expect(signals.every(signal => signal.aborted)).toBe(true);
|
||||
});
|
||||
it("loads the observer right away but activity and telemetry only while the view is open", async () => {
|
||||
const { result, rerender } = renderHook(({ open }) => useAtlasObserver(id, "24h", true, open), { wrapper, initialProps: { open: false } });
|
||||
await tick(10);
|
||||
@@ -44,8 +55,8 @@ describe("useAtlasObserver", () => {
|
||||
rerender({ open: true });
|
||||
await tick(10);
|
||||
expect(result.current.sample.heard).toBe(4);
|
||||
expect(api.getObserverActivity).toHaveBeenCalledWith(id, "24h", "1h", undefined);
|
||||
expect(api.getObserverTelemetry).toHaveBeenCalledWith(id, "24h", "1h");
|
||||
expect(api.getObserverActivity).toHaveBeenCalledWith(id, "24h", "1h", undefined, expect.any(AbortSignal));
|
||||
expect(api.getObserverTelemetry).toHaveBeenCalledWith(id, "24h", "1h", undefined, expect.any(AbortSignal));
|
||||
expect(result.current.sample.uptime).toBe(400);
|
||||
});
|
||||
|
||||
@@ -53,7 +64,7 @@ describe("useAtlasObserver", () => {
|
||||
const { result } = renderHook(() => useAtlasObserver(id, "3d", true, true), { wrapper });
|
||||
await tick(10);
|
||||
expect(result.current.sample.heard).toBe(4);
|
||||
expect(api.getObserverActivity).toHaveBeenLastCalledWith(id, "72h", "1h", undefined);
|
||||
expect(api.getObserverActivity).toHaveBeenLastCalledWith(id, "72h", "1h", undefined, expect.any(AbortSignal));
|
||||
await tick(ATLAS_REFRESH_MS + 50);
|
||||
expect(api.getObserverActivity).toHaveBeenCalledTimes(2);
|
||||
expect(api.getObserver).toHaveBeenCalledTimes(2);
|
||||
|
||||
@@ -25,15 +25,54 @@ function mount() {
|
||||
({ left: index * 100, right: index * 100 + 100, top: 0, bottom: 100, width: 100, height: 100, x: index * 100, y: 0, toJSON() {} }) as DOMRect;
|
||||
});
|
||||
}
|
||||
const move = (x: number, y = 50) => fireEvent(window, new MouseEvent("pointermove", { clientX: x, clientY: y }));
|
||||
const drop = () => fireEvent(window, new MouseEvent("pointerup"));
|
||||
const move = (x: number, y = 50) => pointer("pointermove", window, 0, x, y);
|
||||
const drop = () => pointer("pointerup", window, 0);
|
||||
const pointer = (type: string, target: Element | Window, id: number, x = 50, y = 50) => {
|
||||
const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y });
|
||||
Object.defineProperty(event, "pointerId", { value: id });
|
||||
fireEvent(target, event);
|
||||
};
|
||||
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
describe("useCardDrag", () => {
|
||||
it("ignores movement and release from another pointer", () => {
|
||||
mount();
|
||||
pointer("pointerdown", screen.getByLabelText("handle 0"), 1);
|
||||
pointer("pointermove", window, 2, 250);
|
||||
pointer("pointerup", window, 2);
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
pointer("pointermove", window, 1, 150);
|
||||
pointer("pointerup", window, 1);
|
||||
expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 1);
|
||||
});
|
||||
|
||||
it("ignores a second drag start and removes the first drag's listeners on drop", () => {
|
||||
mount();
|
||||
pointer("pointerdown", screen.getByLabelText("handle 0"), 1);
|
||||
pointer("pointerdown", screen.getByLabelText("handle 1"), 2, 150);
|
||||
pointer("pointermove", window, 1, 250);
|
||||
pointer("pointerup", window, 1);
|
||||
expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 2);
|
||||
pointer("pointermove", window, 2, 150);
|
||||
pointer("pointerup", window, 2);
|
||||
expect(onReorder).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId("card-0").style.transform).toBe("");
|
||||
});
|
||||
|
||||
it("cancels when the window loses focus", () => {
|
||||
mount();
|
||||
pointer("pointerdown", screen.getByLabelText("handle 0"), 1);
|
||||
pointer("pointermove", window, 1, 250);
|
||||
fireEvent.blur(window);
|
||||
pointer("pointerup", window, 1);
|
||||
expect(onReorder).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("card-0").style.transform).toBe("");
|
||||
});
|
||||
|
||||
it("moves the dragged card with the pointer and slides the others out of its way", () => {
|
||||
mount();
|
||||
fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 });
|
||||
pointer("pointerdown", screen.getByLabelText("handle 0"), 0);
|
||||
move(250);
|
||||
expect(screen.getByTestId("card-0")).toHaveStyle({ transform: "translate(200px, 0px)" });
|
||||
expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(-100px, 0px)" });
|
||||
@@ -45,7 +84,7 @@ describe("useCardDrag", () => {
|
||||
|
||||
it("slides cards forward when dragging towards the start", () => {
|
||||
mount();
|
||||
fireEvent.pointerDown(screen.getByLabelText("handle 2"), { clientX: 250, clientY: 50 });
|
||||
pointer("pointerdown", screen.getByLabelText("handle 2"), 0, 250);
|
||||
move(120);
|
||||
expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(100px, 0px)" });
|
||||
expect(screen.getByTestId("card-0").style.transform).toBe("");
|
||||
@@ -55,7 +94,7 @@ describe("useCardDrag", () => {
|
||||
|
||||
it("does nothing on a drop in the same slot or outside every card", () => {
|
||||
mount();
|
||||
fireEvent.pointerDown(screen.getByLabelText("handle 1"), { clientX: 150, clientY: 50 });
|
||||
pointer("pointerdown", screen.getByLabelText("handle 1"), 0, 150);
|
||||
move(160);
|
||||
move(160, 900);
|
||||
drop();
|
||||
@@ -64,7 +103,7 @@ describe("useCardDrag", () => {
|
||||
|
||||
it("cancels on Escape", () => {
|
||||
mount();
|
||||
fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 });
|
||||
pointer("pointerdown", screen.getByLabelText("handle 0"), 0);
|
||||
move(250);
|
||||
fireEvent.keyDown(window, { key: "Escape" });
|
||||
expect(screen.getByTestId("card-0").style.transform).toBe("");
|
||||
|
||||
@@ -31,3 +31,11 @@ it("draws a presence strip with one bar per run of reporting hours", () => {
|
||||
const bars = [...container.querySelectorAll("rect")];
|
||||
expect(bars.map((b) => [b.getAttribute("x"), b.getAttribute("width")])).toEqual([["0", "48"], ["72", "24"]]);
|
||||
});
|
||||
|
||||
|
||||
it("draws isolated samples and dashed gap connectors without filling missing readings", () => {
|
||||
const { container } = render(<StatCard label="History" value="0" accent="red" spark={[0, null, 2]} interpolateGaps />);
|
||||
expect(container.querySelectorAll("[data-sample]")).toHaveLength(2);
|
||||
expect(container.querySelectorAll("[data-gap]")).toHaveLength(1);
|
||||
expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3");
|
||||
});
|
||||
Reference in new issue
Block a user