Atlas: observer view, observer pins and observer search

This commit is contained in:
MrAlders0n committed 2026-10-14 09:06:10 -04:00
1 parent 33333b1a8d
commit b2863816fa
13 files changed
+858 -144

No files matched your search

+143 -53
View File
@@ -1,21 +1,29 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { isNotFound } from "../../api/client";
import { queryRetryDelay } from "../../api/rate-limit";
import { ACTION_BUTTON_CLASS } from "../../components/action-button";
import { Badge } from "../../components/Badge";
import { IataChip } from "../../components/IataChip";
import { InfoTip } from "../../components/InfoTip";
import { Timestamp } from "../../components/Timestamp";
import { formatCount, formatHex } from "../../lib/formatters";
import { formatBattery, formatCount, formatHex, formatRadio, formatRadioParts, formatUptime } from "../../lib/formatters";
import type { Observation } from "../../types/api";
import type { NodeSummary } from "../nodes/types";
import { deriveObserverStatus } from "../observers/observer-status";
import { usePacketDetail } from "../packets/usePacketDetail";
import { Card, StatCard } from "../stats/cards";
import { useChartColors } from "../stats/chartTheme";
import { Segmented } from "../stats/Segmented";
import { average } from "../stats/signal";
import { REPORT_LIMIT, type AtlasPin, type AtlasRange } from "./atlas";
import { useAtlasNode } from "./useAtlas";
import { REPORT_LIMIT, resolveObserverNode, type AtlasPin, type AtlasRange } from "./atlas";
import { ATLAS_REFRESH_MS, useAtlasNode } from "./useAtlas";
import { useAtlasObserver } from "./useAtlasObserver";
import type { CardHandleProps } from "./useCardDrag";
export type AtlasView = "analytics" | "heard" | "observer";
export interface AtlasActions {
onViewNode: (id: string) => void;
onViewObserver: (id: string) => void;
@@ -26,22 +34,27 @@ interface AtlasCardProps extends AtlasActions {
pin: AtlasPin;
range: AtlasRange;
active: boolean;
heardOpen: boolean;
onHeardToggle: () => void;
view: AtlasView;
onViewChange: (view: AtlasView) => void;
onRemove: () => void;
onResolved: (id: string) => void;
onResolved: (id: string, observerId?: string) => void;
onUpgrade: (node: NodeSummary, observerId: string) => void;
handle: Partial<CardHandleProps>;
}
// One pinned node, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way.
const VIEW_LABEL: Record<AtlasView, string> = { analytics: "atlas.viewAnalytics", heard: "atlas.viewHeard", observer: "atlas.viewObserver" };
// One pinned radio, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way.
// A node that also observes gets a third view; an observer-only pin has just that view.
export function AtlasCard({
pin,
range,
active,
heardOpen,
onHeardToggle,
view,
onViewChange,
onRemove,
onResolved,
onUpgrade,
handle,
onViewNode,
onViewObserver,
@@ -49,12 +62,18 @@ export function AtlasCard({
}: AtlasCardProps) {
const { t } = useTranslation();
const c = useChartColors();
const { query, node, sample, capped } = useAtlasNode(pin, range, active);
const nodePin = pin.kind === "node" ? pin : null;
const { query, node, sample, capped } = useAtlasNode(nodePin, range, active);
const observerId = pin.kind === "observer" ? pin.observerId : (node?.observerId ?? pin.observerId ?? null);
const obs = useAtlasObserver(observerId, range, active, view === "observer");
const observer = obs.observer.data;
const views: AtlasView[] = pin.kind === "observer" ? ["observer"] : observerId ? ["analytics", "heard", "observer"] : ["analytics", "heard"];
const latest = sample.reports[0];
const heard = usePacketDetail(heardOpen && latest ? latest.packetHash : null);
const heard = usePacketDetail(view === "heard" && latest ? latest.packetHash : null);
const detail = heard.data;
// Never show another node's observers if the packet record resolved to a different origin.
const sameOrigin = !detail?.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey;
const sameOrigin = !detail?.originPubkey || detail.originPubkey.toLowerCase() === nodePin?.publicKey;
const observers = useMemo(() => {
if (!detail || !sameOrigin) return [];
const latestByObserver = new Map<string, Observation>();
@@ -65,63 +84,78 @@ export function AtlasCard({
return [...latestByObserver.values()].sort((a, b) => b.heardAt - a.heardAt);
}, [detail, sameOrigin]);
// A pin recovered under a new server ID is saved back so the next poll skips the key lookup.
// A pin recovered under a new server ID, or newly linked to an observer, is saved back. A node
// record without the link (older server) keeps whatever link the pin already has.
useEffect(() => {
if (node && node.id !== pin.id) onResolved(node.id);
}, [node, pin.id, onResolved]);
if (!nodePin || !node) return;
const linked = node.observerId ?? nodePin.observerId;
if (node.id !== nodePin.id || linked !== nodePin.observerId) onResolved(node.id, linked);
}, [node, nodePin, onResolved]);
const name = node?.name || pin.name;
// An observer that now advertises becomes a node pin so the card gains the node views. A query,
// not an effect, so the lookup runs once per poll rather than on every parent render.
const upgradeTo = useQuery({
queryKey: ["atlas-observer-node", pin.kind === "observer" ? (observer?.publicKey ?? null) : null],
queryFn: ({ signal }) => resolveObserverNode(observer!, signal),
enabled: pin.kind === "observer" && active && !!observer?.publicKey,
staleTime: ATLAS_REFRESH_MS,
refetchInterval: ATLAS_REFRESH_MS,
refetchIntervalInBackground: false,
refetchOnWindowFocus: false,
retryDelay: queryRetryDelay,
});
useEffect(() => {
if (pin.kind === "observer" && upgradeTo.data) onUpgrade(upgradeTo.data, pin.observerId);
}, [pin, upgradeTo.data, onUpgrade]);
const name = (pin.kind === "node" ? node?.name : observer?.displayName) || pin.name;
const hours = sample.counts.length;
const activeHours = sample.present.filter(Boolean).length;
const status = observer ? deriveObserverStatus(observer) : null;
// The observer detail carries the radio as separate fields; the summary shape has the compact string.
const radio = observer && (formatRadio(observer.radio) ?? formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }));
// The list view is fixed to the tiles' height and scrolls inside it, so flipping never resizes the card.
const body = useRef<HTMLDivElement>(null);
const [held, setHeld] = useState<number>();
const toggleHeard = () => {
if (!heardOpen) setHeld(body.current?.offsetHeight);
onHeardToggle();
const changeView = (next: AtlasView) => {
if (view !== "heard") setHeld(body.current?.offsetHeight);
onViewChange(next);
};
const title = (
<span className="flex min-w-0 flex-wrap items-center gap-2">
<button
type="button"
disabled={!node}
onClick={() => node && onViewNode(node.id)}
disabled={pin.kind === "node" && !node}
onClick={() => (pin.kind === "observer" ? onViewObserver(pin.observerId) : node && onViewNode(node.id))}
className={`min-w-0 max-w-full truncate normal-case disabled:text-text-muted disabled:no-underline ${
name ? "text-primary hover:underline" : "italic text-text-dim"
}`}
>
{name || formatHex(node?.id ?? pin.id)}
{name || formatHex(pin.kind === "node" ? (node?.id ?? pin.id) : pin.observerId)}
</button>
{node && <Badge variant="default">{node.nodeTypeName}</Badge>}
{node?.stale === false && <Badge variant="live">{t("atlas.recent")}</Badge>}
{node?.stale === true && <Badge variant="stale">{t("atlas.stale")}</Badge>}
{pin.kind === "observer" && observer?.observerType && <Badge variant="default">{observer.observerType}</Badge>}
{pin.kind === "observer" && status && (
<Badge variant={status === "online" ? "live" : "stale"}>{t(status === "online" ? "atlas.online" : "atlas.offline")}</Badge>
)}
</span>
);
// Both toggle labels share one grid cell so the button is as wide as the longer one in either
// state; otherwise flipping could push the actions under the title and grow the card.
const labels = [t("atlas.heardBy"), t("tabs.Analytics")];
const actions = (
<div className="ml-auto flex shrink-0 items-center gap-1">
<button
type="button"
className={`${ACTION_BUTTON_CLASS} grid text-center`}
aria-pressed={heardOpen}
aria-label={labels[heardOpen ? 1 : 0]}
disabled={!node}
onClick={toggleHeard}
>
{labels.map((label, index) => {
const shown = index === (heardOpen ? 1 : 0);
return (
<span key={label} aria-hidden={!shown || undefined} className={`col-start-1 row-start-1 ${shown ? "" : "invisible"}`}>
{label}
</span>
);
})}
</button>
{views.length > 1 && (
<Segmented
size="xs"
ariaLabel={t("atlas.view")}
value={view}
onChange={(next) => changeView(next as AtlasView)}
options={views.map((value) => ({ value, label: t(VIEW_LABEL[value]) }))}
/>
)}
<button type="button" className={ACTION_BUTTON_CLASS} onClick={onRemove}>
{t("atlas.remove")}
</button>
@@ -140,21 +174,29 @@ export function AtlasCard({
</div>
);
const retry = (refetch: () => Promise<unknown>) => (
<p role="alert" className="text-sm text-danger">
{t("common.loadFailed")}{" "}
<button type="button" className={ACTION_BUTTON_CLASS} onClick={() => void refetch()}>
{t("observerPage.retry")}
</button>
</p>
);
const dash = "—";
return (
<Card title={title} right={actions} wrap className="flex h-full flex-col">
{query.isPending && <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>}
{query.isError && isNotFound(query.error) && (
{pin.kind === "node" && query.isPending && <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>}
{pin.kind === "node" && query.isError && isNotFound(query.error) && (
<p role="alert" className="text-sm text-text-muted">{t("nodeDetail.notFound")}</p>
)}
{query.isError && !isNotFound(query.error) && (
<p role="alert" className="text-sm text-danger">
{t("common.loadFailed")}{" "}
<button type="button" className={ACTION_BUTTON_CLASS} onClick={() => void query.refetch()}>
{t("observerPage.retry")}
</button>
</p>
{pin.kind === "node" && query.isError && !isNotFound(query.error) && retry(query.refetch)}
{pin.kind === "observer" && obs.observer.isPending && <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>}
{pin.kind === "observer" && obs.observer.isError && isNotFound(obs.observer.error) && (
<p role="alert" className="text-sm text-text-muted">{t("atlas.observerGone")}</p>
)}
{node && !heardOpen && (
{pin.kind === "observer" && obs.observer.isError && !isNotFound(obs.observer.error) && retry(obs.observer.refetch)}
{view === "analytics" && node && (
<div ref={body} className="flex flex-1 flex-col gap-3">
<div className="grid flex-1 grid-cols-2 gap-3">
<StatCard
@@ -204,7 +246,7 @@ export function AtlasCard({
</div>
</div>
)}
{node && heardOpen && (
{view === "heard" && node && (
<div className="flex flex-col overflow-hidden font-mono text-xs" style={{ height: held }}>
{!latest ? (
<p className="text-text-muted">{t("atlas.noReports")}</p>
@@ -242,6 +284,54 @@ export function AtlasCard({
)}
</div>
)}
{view === "observer" && observerId && (pin.kind === "observer" ? observer : node) && (
<div ref={body} className="flex flex-1 flex-col gap-3">
<div className="grid flex-1 grid-cols-2 gap-3">
<StatCard
compact
label={t("atlas.packetsHeard")}
value={obs.sample.heard === null ? dash : formatCount(obs.sample.heard)}
accent={c.series[0]!}
spark={obs.sample.counts}
markPeak
sublabel={t(`atlas.ranges.${range}`)}
/>
<StatCard
compact
label={t("atlas.meanSnrHeard")}
value={obs.sample.snr === null ? dash : `${obs.sample.snr.toFixed(1)} dB`}
accent={c.series[2]!}
spark={obs.sample.snrHourly}
/>
<StatCard
compact
label={t("atlas.noiseFloor")}
value={obs.sample.noise === null ? dash : `${Math.round(obs.sample.noise)} dB`}
accent={c.series[1]!}
spark={obs.sample.noiseHourly}
/>
{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.uptime")} value={obs.sample.uptime === null ? dash : formatUptime(obs.sample.uptime)} accent={c.series[3]!} />
)}
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 font-mono text-[11px] text-text-muted">
{status && <span>{t(status === "online" ? "atlas.online" : "atlas.offline")}</span>}
{observer?.lastStatusAt != null && (
<span>
{t("atlas.lastStatus")} <Timestamp value={observer.lastStatusAt} />
</span>
)}
{radio && <span>{radio}</span>}
{obs.loading && <span role="status">{t("common.loading")}</span>}
{obs.failed && <span className="text-danger">{t("common.loadFailed")}</span>}
<span className="whitespace-nowrap">
<InfoTip text={[t("atlas.observerHelp"), obs.unsupported && t("atlas.observerUnsupported")]} />
</span>
</div>
</div>
)}
</Card>
);
}
+106 -45
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getNodesPage } from "../../api/client";
import { getNodesPage, getObserver, getObserverDirectoryPage } from "../../api/client";
import { ACTION_BUTTON_CLASS } from "../../components/action-button";
import { Badge } from "../../components/Badge";
import { EmptyState } from "../../components/EmptyState";
@@ -12,10 +12,13 @@ import { useIsMobile } from "../../hooks/useMediaQuery";
import { formatHex } from "../../lib/formatters";
import { nodeSearchParams } from "../nodes/node-search";
import type { NodeSummary } from "../nodes/types";
import { observerName } from "../observers/observer-filter";
import { deriveObserverStatus } from "../observers/observer-status";
import type { ObserverDirectoryItem } from "../observers/types";
import { Card } from "../stats/cards";
import { Segmented } from "../stats/Segmented";
import { AtlasCard, type AtlasActions } from "./AtlasCard";
import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas";
import { AtlasCard, type AtlasActions, type AtlasView } from "./AtlasCard";
import { ATLAS_LIMIT, parseAtlas, pinKey, resolveObserverNode, type AtlasPin, type AtlasRange } from "./atlas";
import { useCardDrag } from "./useCardDrag";
import { useSavedAtlas } from "./useSavedAtlas";
@@ -23,7 +26,11 @@ const RANGES: AtlasRange[] = ["24h", "3d"];
const SEARCH_LIMIT = 8;
const SEARCH_MIN = 2;
// Global by design: pins are node identities, so the header region picker does not apply here.
type Matches =
| { kind: "node"; items: NodeSummary[]; hasMore: boolean }
| { kind: "observer"; items: ObserverDirectoryItem[]; hasMore: boolean };
// Global by design: pins are radio identities, so the header region picker does not apply here.
export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) {
const { t } = useTranslation();
const { saved, update, storageFailed } = useSavedAtlas();
@@ -31,7 +38,11 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
const isMobile = useIsMobile();
const [search, setSearch] = useState("");
const [field, setField] = useState("name");
const [heardKey, setHeardKey] = useState<string | null>(null);
// One card at a time is away from its default view.
const [open, setOpen] = useState<{ key: string; view: AtlasView } | null>(null);
const defaultView = (pin: AtlasPin): AtlasView => (pin.kind === "observer" ? "observer" : "analytics");
const viewOf = (pin: AtlasPin) => (open?.key === pinKey(pin) ? open.view : defaultView(pin));
const setView = (pin: AtlasPin, view: AtlasView) => setOpen(view === defaultView(pin) ? null : { key: pinKey(pin), view });
// Switching the field changes what the box means, so stale text must not carry over.
const changeField = (value: string) => {
setField(value);
@@ -39,6 +50,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
};
// Key on the derived params, as the Nodes table does, so a name is never sent as a hex prefix.
const observerSearch = field === "observer";
const params = nodeSearchParams(field, search);
const typed = search.trim();
const hint = typed.length === 0 ? null
@@ -46,43 +58,83 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
: field === "pubkey" && params.pubkeyPrefix === undefined ? "atlas.searchNotHex"
: null;
const ready = typed.length > 0 && hint === null;
const results = useQuery({
queryKey: ["atlas-search", params.name, params.pubkeyPrefix],
queryFn: ({ signal }) => getNodesPage(undefined, { ...params, limit: SEARCH_LIMIT }, signal),
const results = useQuery<Matches>({
queryKey: observerSearch ? ["atlas-search-observers", typed] : ["atlas-search", params.name, params.pubkeyPrefix],
queryFn: ({ signal }) => observerSearch
? getObserverDirectoryPage({ name: typed, limit: SEARCH_LIMIT }, signal).then((page) => ({ kind: "observer", items: page.items, hasMore: page.hasMore }))
: getNodesPage(undefined, { ...params, limit: SEARCH_LIMIT }, signal).then((page) => ({ kind: "node", items: page.items, hasMore: page.hasMore })),
enabled: active && ready,
staleTime: 30_000,
});
const full = saved.nodes.length >= ATLAS_LIMIT;
const full = saved.pins.length >= ATLAS_LIMIT;
const fields: SearchFieldOption[] = [
{ value: "name", label: t("nodeFilters.fieldName") },
{ value: "pubkey", label: t("nodeFilters.fieldPubkey") },
{ value: "observer", label: t("atlas.fieldObserver") },
];
const rangeOptions = RANGES.map((value) => ({ value, label: t(`atlas.ranges.${value}`) }));
// Re-parse so an add goes through the same validation, dedupe and cap as a restore.
const append = (pin: AtlasPin) =>
update((value) => parseAtlas(JSON.stringify({ ...value, pins: [...value.pins, pin] })));
const add = (node: NodeSummary) =>
append({ kind: "node", id: node.id, publicKey: node.publicKey, name: node.name || "", observerId: node.observerId });
// An observer whose radio advertises is saved as that node; if the node is pinned already, it
// just gains the link, so the result reads Saved instead of silently doing nothing.
const addObserver = useMutation({
mutationFn: async (row: ObserverDirectoryItem) => {
const observer = await getObserver(row.id);
const match = await resolveObserverNode(observer);
const name = row.displayName || "";
if (!match) return append({ kind: "observer", observerId: row.id, name });
const publicKey = match.publicKey.toLowerCase();
update((value) => value.pins.some((pin) => pin.kind === "node" && pin.publicKey === publicKey)
? { ...value, pins: value.pins.map((pin) => (pin.kind === "node" && pin.publicKey === publicKey ? { ...pin, observerId: row.id } : pin)) }
: parseAtlas(JSON.stringify({ ...value, pins: [...value.pins, { kind: "node", id: match.id, publicKey, name: match.name || name, observerId: row.id }] })));
},
});
const remove = (pin: AtlasPin) =>
update((value) => ({ ...value, pins: value.pins.filter((other) => pinKey(other) !== pinKey(pin)) }));
const relink = (pin: AtlasPin, id: string, observerId?: string) =>
update((value) => ({ ...value, pins: value.pins.map((other) => (pinKey(other) === pinKey(pin) && other.kind === "node" ? { ...other, id, observerId } : other)) }));
// An observer pin whose key now resolves to a node takes that node's place in its own slot.
const upgrade = (pin: AtlasPin, match: NodeSummary, observerId: string) =>
update((value) => {
const pin = { id: node.id, publicKey: node.publicKey, name: node.name || "" };
return parseAtlas(JSON.stringify({ ...value, nodes: [...value.nodes, pin] }));
const publicKey = match.publicKey.toLowerCase();
if (value.pins.some((other) => other.kind === "node" && other.publicKey === publicKey)) {
return { ...value, pins: value.pins.filter((other) => pinKey(other) !== pinKey(pin)) };
}
return { ...value, pins: value.pins.map((other) => (pinKey(other) === pinKey(pin)
? { kind: "node", id: match.id, publicKey, name: match.name || pin.name, observerId }
: other)) };
});
const remove = (publicKey: string) =>
update((value) => ({ ...value, nodes: value.nodes.filter((node) => node.publicKey !== publicKey) }));
const relink = (publicKey: string, id: string) =>
update((value) => ({ ...value, nodes: value.nodes.map((node) => (node.publicKey === publicKey ? { ...node, id } : node)) }));
const reorder = (from: number, to: number) =>
update((value) => {
const nodes = [...value.nodes];
const [moved] = nodes.splice(from, 1);
if (!moved || to < 0 || to >= value.nodes.length) return value;
nodes.splice(to, 0, moved);
return { ...value, nodes };
const pins = [...value.pins];
const [moved] = pins.splice(from, 1);
if (!moved || to < 0 || to >= value.pins.length) return value;
pins.splice(to, 0, moved);
return { ...value, pins };
});
const drag = useCardDrag(saved.nodes.length, reorder);
const drag = useCardDrag(saved.pins.length, reorder);
const cardProps = (index: number) => {
const props = drag.cardProps(index);
return { ...props, className: `min-w-0 ${props.className ?? ""}` };
};
const pinnedObserver = (id: string) => saved.pins.some((pin) => pin.observerId === id);
const addButton = (pinned: boolean, label: string, onClick: () => void, busy = false) => (
<button
type="button"
className={ACTION_BUTTON_CLASS}
disabled={pinned || full || busy}
aria-label={t(pinned ? "atlas.savedName" : "atlas.addName", { name: label })}
onClick={onClick}
>
{t(pinned ? "atlas.saved" : "atlas.add")}
</button>
);
const rowClass = "flex items-center justify-between gap-2 border-t border-border-subtle py-1.5 first:border-t-0";
return (
<section className="flex min-h-0 min-w-0 flex-1 flex-col" aria-label={t("tabs.MyAtlas")}>
@@ -103,7 +155,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
<span className="ml-auto flex shrink-0 items-center gap-2 font-mono text-[11px] text-text-muted">
<Tooltip label={t("atlas.localOnly")}>
<span className={`rounded-sm border px-2.5 py-1 ${full ? "border-warn/15 bg-warn/7 text-warn" : "border-border bg-bg-surface"}`}>
{t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}
{t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.pins.length, limit: ATLAS_LIMIT })}
</span>
</Tooltip>
{storageFailed && <span role="status" className="text-warn">{t("atlas.storageFailed")}</span>}
@@ -135,37 +187,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
</p>
) : results.data.items.length === 0 ? (
<p className="text-text-muted">{t("common.noMatches")}</p>
) : (
) : results.data.kind === "node" ? (
<ul>
{results.data.items.map((node) => {
const pinned = saved.nodes.some((pin) => pin.publicKey === node.publicKey.toLowerCase());
const pinned = saved.pins.some((pin) => pin.kind === "node" && pin.publicKey === node.publicKey.toLowerCase());
const label = node.name ?? formatHex(node.id);
return (
<li
key={node.publicKey}
className="flex items-center justify-between gap-2 border-t border-border-subtle py-1.5 first:border-t-0"
>
<li key={node.publicKey} className={rowClass}>
<span className="flex min-w-0 items-center gap-2">
<span className={`truncate ${node.name ? "text-text-bright" : "italic text-text-dim"}`}>{label}</span>
<Badge variant="default">{node.nodeTypeName}</Badge>
{node.isObserver && <Badge variant="default">{t("atlas.observerBadge")}</Badge>}
<Tooltip label={node.publicKey}>
<code className="text-text-dim">{formatHex(node.publicKey)}</code>
</Tooltip>
</span>
<button
type="button"
className={ACTION_BUTTON_CLASS}
disabled={pinned || full}
aria-label={t(pinned ? "atlas.savedName" : "atlas.addName", { name: label })}
onClick={() => add(node)}
>
{t(pinned ? "atlas.saved" : "atlas.add")}
</button>
{addButton(pinned, label, () => add(node))}
</li>
);
})}
</ul>
) : (
<ul>
{results.data.items.map((row) => {
const label = observerName(row);
const online = deriveObserverStatus(row) === "online";
return (
<li key={row.id} className={rowClass}>
<span className="flex min-w-0 items-center gap-2">
<span aria-hidden className={`h-1.5 w-1.5 shrink-0 rounded-full ${online ? "bg-green" : "bg-text-dim/30"}`} />
<span className={`truncate ${row.displayName ? "text-text-bright" : "italic text-text-dim"}`}>{label}</span>
{row.observerType && <Badge variant="default">{row.observerType}</Badge>}
</span>
{addButton(pinnedObserver(row.id), label, () => addObserver.mutate(row), addObserver.isPending)}
</li>
);
})}
</ul>
)}
{addObserver.isError && <p role="alert" className="mt-1 text-danger">{t("common.loadFailed")}</p>}
{results.data?.hasMore && !hint && (
<p className="mt-1 text-text-muted">{t("atlas.refine", { limit: SEARCH_LIMIT })}</p>
)}
@@ -173,20 +233,21 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
</div>
</Card>
)}
{saved.nodes.length === 0 ? (
{saved.pins.length === 0 ? (
<EmptyState title={t("atlas.emptyTitle")} subtitle={t("atlas.emptyDescription", { limit: ATLAS_LIMIT })} />
) : (
<div className="grid gap-3.5 md:grid-cols-2 xl:grid-cols-3">
{saved.nodes.map((pin, index) => (
<div key={pin.publicKey} data-atlas-card {...cardProps(index)}>
{saved.pins.map((pin, index) => (
<div key={pinKey(pin)} data-atlas-card {...cardProps(index)}>
<AtlasCard
pin={pin}
range={saved.range}
active={active}
heardOpen={heardKey === pin.publicKey}
onHeardToggle={() => setHeardKey((key) => (key === pin.publicKey ? null : pin.publicKey))}
onRemove={() => remove(pin.publicKey)}
onResolved={(id) => relink(pin.publicKey, id)}
view={viewOf(pin)}
onViewChange={(view) => setView(pin, view)}
onRemove={() => remove(pin)}
onResolved={(id, observerId) => relink(pin, id, observerId)}
onUpgrade={(match, observerId) => upgrade(pin, match, observerId)}
handle={drag.handleProps(index)}
{...actions}
/>
+44 -15
View File
@@ -1,34 +1,63 @@
import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from "../../api/client";
import type { Node, NodeObservation } from "../nodes/types";
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_LIMIT = 12;
export const REPORT_LIMIT = 200;
export type AtlasRange = "24h" | "3d";
export interface AtlasPin { id: string; publicKey: string; name: string }
export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] }
export interface NodePin { kind: "node"; id: string; publicKey: string; name: string; observerId?: string }
export interface ObserverPin { kind: "observer"; observerId: string; name: string }
export type AtlasPin = NodePin | ObserverPin;
export interface SavedAtlas { version: 2; range: AtlasRange; pins: AtlasPin[] }
const UUID = /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i;
const KEY = /^[\da-f]{64}$/i;
const isUuid = (value: unknown): value is string => typeof value === "string" && UUID.test(value);
export const pinKey = (pin: AtlasPin) => (pin.kind === "node" ? pin.publicKey : pin.observerId);
// Version 1 stored `nodes`; version 2 stores `pins` with a kind. Both load, anything else is empty.
export function parseAtlas(raw: string | null): SavedAtlas {
const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] };
const empty: SavedAtlas = { version: 2, range: "24h", pins: [] };
try {
if (!raw || raw.length > 32_000) return empty;
const value = JSON.parse(raw);
if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty;
const nodes: AtlasPin[] = [];
for (const pin of value.nodes) {
if (typeof pin?.id !== "string" || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id) ||
typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue;
const publicKey = pin.publicKey.toLowerCase();
if (nodes.some(node => node.publicKey === publicKey)) continue;
nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" });
if (nodes.length === ATLAS_LIMIT) break;
const list = value?.version === 1 ? value.nodes : value?.version === 2 ? value.pins : null;
if (!Array.isArray(list)) return empty;
const pins: AtlasPin[] = [];
for (const entry of list) {
const pin = toPin(entry, value.version === 1);
if (!pin || pins.some((other) => pinKey(other) === pinKey(pin))) continue;
pins.push(pin);
if (pins.length === ATLAS_LIMIT) break;
}
return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes };
return { version: 2, range: value.range === "3d" ? "3d" : "24h", pins };
} catch { return empty; }
}
function toPin(raw: unknown, legacy: boolean): AtlasPin | null {
if (!raw || typeof raw !== "object") return null;
const value = raw as Record<string, unknown>;
const name = typeof value.name === "string" ? value.name.slice(0, 160) : "";
const kind = legacy ? "node" : value.kind;
if (kind === "observer") return isUuid(value.observerId) ? { kind, observerId: value.observerId, name } : null;
if (kind !== "node" || !isUuid(value.id) || typeof value.publicKey !== "string" || !KEY.test(value.publicKey)) return null;
const pin: NodePin = { kind, id: value.id, publicKey: value.publicKey.toLowerCase(), name };
if (isUuid(value.observerId)) pin.observerId = value.observerId;
return pin;
}
// The same radio can be a node and an observer; when it advertises, the node is the pin.
export async function resolveObserverNode(observer: Observer, signal?: AbortSignal): Promise<NodeSummary | null> {
const key = observer.publicKey?.toLowerCase();
if (!key) return null;
const page = await getNodesPage(undefined, { pubkeyPrefix: key, limit: 2 }, signal);
return page.items.find((row) => row.publicKey.toLowerCase() === key) ?? null;
}
// Resolve by full identity after a server database reset; never adopt a different node at an old ID.
export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) {
export async function loadAtlasNode(pin: NodePin, signal?: AbortSignal) {
let node: Node | undefined;
try {
node = await getNode(pin.id, signal);
+81
View File
@@ -0,0 +1,81 @@
import type { Observer } from "../observers/types";
import { hasTelemetry } from "../stats/transforms";
import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../stats/types";
import type { AtlasRange } from "./atlas";
// Go duration strings the observer endpoints expect, per Atlas window; one bucket per hour for both.
export const OBSERVER_RANGE: Record<AtlasRange, string> = { "24h": "24h", "3d": "72h" };
export const OBSERVER_INTERVAL = "1h";
const RANGE_HOURS: Record<AtlasRange, number> = { "24h": 24, "3d": 72 };
const HOUR = 3_600_000;
export interface ObserverSample {
heard: number | null;
counts: (number | null)[];
snrHourly: (number | null)[];
snr: number | null;
noiseHourly: (number | null)[];
noise: number | null;
batteryHourly: (number | null)[];
battery: number | null;
uptime: number | null;
}
export function summarizeObserver(
activity: ObserverActivity | undefined,
telemetry: ObserverTelemetry | undefined,
observer: Observer | undefined,
range: AtlasRange,
now: number,
): ObserverSample {
const hours = RANGE_HOURS[range];
// 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 counts = Array<number | null>(hours).fill(0);
const snrHourly = Array<number | null>(hours).fill(null);
let heard: number | null = 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) {
counts[i] = null;
continue;
}
counts[i] = (counts[i] ?? 0) + p.observations;
heard = (heard ?? 0) + p.observations;
if (p.snrAvg !== null && p.observations > 0) {
snrHourly[i] = p.snrAvg;
weighted += p.snrAvg * p.observations;
weight += p.observations;
}
}
// A stray all-zero status row is "no reading", not a 0 dB floor or an empty battery.
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];
return out;
};
const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => p[key] !== null)?.[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");
return {
heard,
counts,
snrHourly,
snr: weight ? weighted / weight : null,
noiseHourly: series("noiseFloorDb"),
noise: latest("noiseFloorDb") ?? null,
batteryHourly: series("batteryMv").map(volts),
battery: volts(latest("batteryMv")),
uptime: uptime ?? null,
};
}
+6 -5
View File
@@ -3,19 +3,20 @@ import { useQuery } from "@tanstack/react-query";
import { queryRetryDelay } from "../../api/rate-limit";
import { useRateLimit } from "../../hooks/useRateLimit";
import { useTick } from "../../hooks/useTick";
import { loadAtlasNode, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas";
import { loadAtlasNode, summarizeReports, type NodePin, type AtlasRange } from "./atlas";
export const ATLAS_REFRESH_MS = 60_000;
export function useAtlasNode(pin: AtlasPin, range: AtlasRange, active: boolean) {
// A null pin (an observer-only card) leaves the query disabled.
export function useAtlasNode(pin: NodePin | null, range: AtlasRange, active: boolean) {
const now = useTick(ATLAS_REFRESH_MS);
const limitedUntil = useRateLimit();
// Disabling the query drops its timer, so a poll can't fire inside the throttle window.
const throttled = limitedUntil !== null && now < limitedUntil;
const query = useQuery({
queryKey: ["atlas-node", pin.publicKey],
queryFn: ({ signal }) => loadAtlasNode(pin, signal),
enabled: active && !throttled,
queryKey: ["atlas-node", pin?.publicKey ?? null],
queryFn: ({ signal }) => loadAtlasNode(pin!, signal),
enabled: pin !== null && active && !throttled,
staleTime: ATLAS_REFRESH_MS,
refetchOnWindowFocus: false,
refetchInterval: ATLAS_REFRESH_MS,
+58
View File
@@ -0,0 +1,58 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { getObserver, getObserverActivity, getObserverTelemetry, isNotFound } from "../../api/client";
import { queryRetryDelay } from "../../api/rate-limit";
import { useRateLimit } from "../../hooks/useRateLimit";
import { useTick } from "../../hooks/useTick";
import type { AtlasRange } from "./atlas";
import { OBSERVER_INTERVAL, OBSERVER_RANGE, summarizeObserver } from "./observer-sample";
import { ATLAS_REFRESH_MS } from "./useAtlas";
// A 404 means this server has no such endpoint; nothing to poll for.
const pollUnlessMissing = (error: unknown) => (isNotFound(error) ? false : ATLAS_REFRESH_MS);
// The observer record loads whenever the card is active; the series only while the Observer view is open.
export function useAtlasObserver(observerId: string | null, range: AtlasRange, active: boolean, open: boolean) {
const now = useTick(ATLAS_REFRESH_MS);
const limitedUntil = useRateLimit();
const throttled = limitedUntil !== null && now < limitedUntil;
const base = {
staleTime: ATLAS_REFRESH_MS,
refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
retryDelay: queryRetryDelay,
};
const observer = useQuery({
...base,
queryKey: ["atlas-observer", observerId],
queryFn: () => getObserver(observerId!),
enabled: active && !throttled && observerId !== null,
refetchInterval: ATLAS_REFRESH_MS,
});
const series = active && !throttled && open && observerId !== null;
const activity = useQuery({
...base,
queryKey: ["atlas-observer-activity", observerId, range],
queryFn: () => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined),
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),
enabled: series,
refetchInterval: (query) => pollUnlessMissing(query.state.error),
});
const sample = useMemo(
() => summarizeObserver(activity.data, telemetry.data, observer.data, range, now),
[activity.data, telemetry.data, observer.data, range, now],
);
return {
observer,
sample,
loading: open && (activity.isPending || telemetry.isPending),
unsupported: isNotFound(activity.error) || isNotFound(telemetry.error),
failed: (activity.isError && !isNotFound(activity.error)) || (telemetry.isError && !isNotFound(telemetry.error)),
};
}
+17
View File
@@ -994,6 +994,23 @@
"noReports": "No observations in this window.",
"heardUnavailable": "Packet details are unavailable or expired.",
"openPacket": "Open packet",
"fieldObserver": "Observer",
"observerBadge": "Observer",
"viewAnalytics": "Analytics",
"viewHeard": "Heard by",
"viewObserver": "Observer",
"view": "Card view",
"packetsHeard": "Packets heard",
"meanSnrHeard": "Mean SNR heard",
"noiseFloor": "Noise floor",
"battery": "Battery",
"uptime": "Uptime",
"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.",
"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"
}
+17
View File
@@ -997,6 +997,23 @@
"noReports": "Aucune observation sur cette période.",
"heardUnavailable": "Détails du paquet indisponibles ou expirés.",
"openPacket": "Ouvrir le paquet",
"fieldObserver": "Observateur",
"observerBadge": "Observateur",
"viewAnalytics": "Analyses",
"viewHeard": "Entendu par",
"viewObserver": "Observateur",
"view": "Vue de la carte",
"packetsHeard": "Paquets reçus",
"meanSnrHeard": "SNR moyen reçu",
"noiseFloor": "Plancher de bruit",
"battery": "Batterie",
"uptime": "Disponibilité",
"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.",
"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"
}
+115 -16
View File
@@ -1,11 +1,12 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { AtlasCard } from "../../../src/features/atlas/AtlasCard";
import * as api from "../../../src/api/client";
import { ApiError } from "../../../src/api/client";
import { formatHex } from "../../../src/lib/formatters";
import type { Node } from "../../../src/features/nodes/types";
import type { Observer } from "../../../src/features/observers/types";
import type { PacketDetail } from "../../../src/types/api";
vi.mock("../../../src/api/client", async (importOriginal) => ({
@@ -14,9 +15,12 @@ vi.mock("../../../src/api/client", async (importOriginal) => ({
getNodeObservations: vi.fn(),
getNodesPage: vi.fn(),
getPacketDetail: vi.fn(),
getObserver: vi.fn(),
getObserverActivity: vi.fn(),
getObserverTelemetry: vi.fn(),
}));
const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" };
const pin = { kind: "node" as const, id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" };
const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node;
const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 };
const detail = { packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [
@@ -24,20 +28,25 @@ const detail = { packetHash: report.packetHash, originPubkey: pin.publicKey, hea
{ observerId: "observer-a", observerName: "Home listener", id: 2, heardAt: 2, iata: "YKF" },
{ observerId: "observer-b", observerName: "Hill listener", id: 3, heardAt: 3, iata: "YKF" },
] } as unknown as PacketDetail;
const observerId = "33333333-3333-4333-8333-333333333333";
const observer = { id: observerId, status: "online", lastStatusAt: Date.now() - 60_000, iata: "YKF", observerType: "repeater", radioFreqMhz: 915, radioSf: 11, radioBwKhz: 250, publicKey: pin.publicKey, firstSeen: 1, lastSeen: 2, observationCount: 5, brokers: [], uptimeSeconds: 90_000 } as Observer;
const activity = { range: "24h", interval: "1h", radio: null, payloadTypes: [], points: [{ t: Date.now() - 3_600_000, observations: 12, snrAvg: 6.5, snrMin: null, rssiAvg: null, airtimeMs: null }] };
const telemetry = { range: "24h", interval: "1h", points: [{ t: Date.now() - 3_600_000, batteryMv: 4120, noiseFloorDb: -117, airtimeTxSecs: null, airtimeRxSecs: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }] };
const handlers = {
onViewNode: vi.fn(),
onViewObserver: vi.fn(),
onAnalyzePacket: vi.fn(),
onHeardToggle: vi.fn(),
onViewChange: vi.fn(),
onRemove: vi.fn(),
onResolved: vi.fn(),
onUpgrade: vi.fn(),
};
let client: QueryClient;
function mount(overrides: Partial<Parameters<typeof AtlasCard>[0]> = {}) {
return render(
<QueryClientProvider client={client}>
<AtlasCard pin={pin} range="24h" active heardOpen={false} handle={{}} {...handlers} {...overrides} />
<AtlasCard pin={pin} range="24h" active view="analytics" handle={{}} {...handlers} {...overrides} />
</QueryClientProvider>,
);
}
@@ -81,7 +90,7 @@ describe("AtlasCard", () => {
vi.mocked(api.getNode).mockResolvedValue({ ...node, id: recovered });
mount();
await screen.findByRole("button", { name: "Renamed repeater" });
expect(handlers.onResolved).toHaveBeenCalledWith(recovered);
expect(handlers.onResolved).toHaveBeenCalledWith(recovered, undefined);
});
it("leaves the pin alone when the server ID still matches", async () => {
@@ -141,22 +150,18 @@ describe("AtlasCard", () => {
expect(api.getPacketDetail).not.toHaveBeenCalled();
expect(screen.queryByRole("button", { name: /Move/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Drag to reorder" })).toBeInTheDocument();
const toggle = screen.getByRole("button", { name: "Heard by" });
expect(toggle).toHaveAttribute("aria-pressed", "false");
// Both labels stay in the button, stacked, so flipping never changes its width and wraps the header.
expect(toggle.querySelector('[aria-hidden="true"]')).toHaveTextContent("Analytics");
expect(toggle.querySelector('[aria-hidden="true"]')).toHaveClass("invisible", "col-start-1", "row-start-1");
fireEvent.click(toggle);
expect(handlers.onHeardToggle).toHaveBeenCalled();
const picker = screen.getByRole("group", { name: "Card view" });
expect(within(picker).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true");
fireEvent.click(within(picker).getByRole("button", { name: "Heard by" }));
expect(handlers.onViewChange).toHaveBeenCalledWith("heard");
view.rerender(
<QueryClientProvider client={client}>
<AtlasCard pin={pin} range="24h" active heardOpen handle={{}} {...handlers} />
<AtlasCard pin={pin} range="24h" active view="heard" handle={{}} {...handlers} />
</QueryClientProvider>,
);
const summary = await screen.findByText(/2 observers heard the latest packet/);
expect(summary).toHaveTextContent(/ago$/);
expect(screen.getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByRole("button", { name: "Analytics" }).querySelector('[aria-hidden="true"]')).toHaveTextContent("Heard by");
expect(screen.getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "true");
expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument();
expect(summary.parentElement).toHaveStyle({ height: "240px" });
expect(summary.nextElementSibling).toHaveClass("overflow-y-auto");
@@ -171,7 +176,7 @@ describe("AtlasCard", () => {
it("hides observer links for a different origin identity and explains expired packets", async () => {
vi.mocked(api.getPacketDetail).mockResolvedValue({ ...detail, originPubkey: "cd".repeat(32) });
mount({ heardOpen: true });
mount({ view: "heard" });
expect(await screen.findByText("Packet details are unavailable or expired.")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Open packet" })).not.toBeInTheDocument();
});
@@ -185,4 +190,98 @@ describe("AtlasCard", () => {
expect(handlers.onRemove).toHaveBeenCalled();
expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument();
});
it("offers the Observer view only when the node is also an observer, and tells the page the link", async () => {
const first = mount();
await screen.findByRole("button", { name: "Renamed repeater" });
const picker = screen.getByRole("group", { name: "Card view" });
expect(within(picker).getAllByRole("button").map((b) => b.textContent)).toEqual(["Analytics", "Heard by"]);
expect(handlers.onResolved).not.toHaveBeenCalled();
first.unmount();
client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
vi.mocked(api.getNode).mockResolvedValue({ ...node, isObserver: true, observerId });
vi.mocked(api.getObserver).mockResolvedValue(observer);
mount({ pin: { ...pin, id: "22222222-2222-4222-8222-222222222222" } });
await waitFor(() => expect(handlers.onResolved).toHaveBeenCalledWith(node.id, observerId));
expect(within(screen.getByRole("group", { name: "Card view" })).getAllByRole("button")).toHaveLength(3);
});
it("renders the Observer view tiles from activity and telemetry in the same frame", async () => {
vi.mocked(api.getNode).mockResolvedValue({ ...node, isObserver: true, observerId });
vi.mocked(api.getObserver).mockResolvedValue(observer);
vi.mocked(api.getObserverActivity).mockResolvedValue(activity as never);
vi.mocked(api.getObserverTelemetry).mockResolvedValue(telemetry as never);
mount({ view: "observer" });
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("4.12V")).toBeInTheDocument();
expect(screen.getByText("Online")).toBeInTheDocument();
expect(screen.getByText("915 MHz · SF11 · 250 kHz").closest(".flex-wrap")).toBeInTheDocument();
expect(screen.getByText("Packets heard").closest(".grid")).toHaveClass("flex-1", "grid-cols-2");
expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Observer" })).toHaveAttribute("aria-pressed", "true");
});
it("shows dashes and explains when the server lacks the observer endpoints", async () => {
vi.mocked(api.getNode).mockResolvedValue({ ...node, isObserver: true, observerId });
vi.mocked(api.getObserver).mockResolvedValue(observer);
vi.mocked(api.getObserverActivity).mockRejectedValue(new ApiError(404, "not_found", "no"));
vi.mocked(api.getObserverTelemetry).mockRejectedValue(new ApiError(404, "not_found", "no"));
mount({ view: "observer" });
expect(await screen.findByText(/More information:.*does not provide observer activity/)).toBeInTheDocument();
expect(screen.getAllByText("—")).toHaveLength(3);
expect(screen.getByText("1d 1h 0m")).toBeInTheDocument();
});
it("renders an observer-only pin with its status badge, a dashboard link and no picker", async () => {
vi.mocked(api.getObserver).mockResolvedValue({ ...observer, publicKey: "" });
vi.mocked(api.getObserverActivity).mockResolvedValue(activity as never);
vi.mocked(api.getObserverTelemetry).mockResolvedValue(telemetry as never);
mount({ pin: { kind: "observer", observerId, name: "Hill listener" }, view: "observer" });
expect(await screen.findByText("repeater")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Hill listener" }));
expect(handlers.onViewObserver).toHaveBeenCalledWith(observerId);
expect(screen.getAllByText("Online")[0]).toHaveClass("bg-green/8");
expect(screen.queryByRole("group", { name: "Card view" })).not.toBeInTheDocument();
expect(await screen.findByText("12")).toBeInTheDocument();
expect(api.getNode).not.toHaveBeenCalled();
});
it("looks an observer pin's node up once per poll, not on every parent render", async () => {
vi.mocked(api.getObserver).mockResolvedValue(observer);
vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null });
const observerPin = { kind: "observer" as const, observerId, name: "Hill listener" };
const view = mount({ pin: observerPin, view: "observer" });
await waitFor(() => expect(api.getNodesPage).toHaveBeenCalledTimes(1));
for (let i = 0; i < 3; i++) {
view.rerender(
<QueryClientProvider client={client}>
<AtlasCard pin={{ ...observerPin }} range="24h" active view="observer" handle={{}} {...handlers} onUpgrade={vi.fn()} />
</QueryClientProvider>,
);
}
await screen.findByText("repeater");
expect(api.getNodesPage).toHaveBeenCalledTimes(1);
expect(handlers.onUpgrade).not.toHaveBeenCalled();
});
it("says it is loading the observer series and keeps a saved link a node record does not carry", async () => {
vi.mocked(api.getNode).mockResolvedValue({ ...node, id: "22222222-2222-4222-8222-222222222222" });
vi.mocked(api.getObserver).mockResolvedValue(observer);
vi.mocked(api.getObserverActivity).mockReturnValue(new Promise(() => {}));
vi.mocked(api.getObserverTelemetry).mockReturnValue(new Promise(() => {}));
mount({ pin: { ...pin, observerId }, view: "observer" });
expect(await screen.findByText("Packets heard")).toBeInTheDocument();
expect(screen.getByRole("status")).toHaveTextContent("Loading");
await waitFor(() => expect(handlers.onResolved).toHaveBeenCalledWith("22222222-2222-4222-8222-222222222222", observerId));
});
it("upgrades an observer-only pin to a node pin when its key now resolves to a node", async () => {
vi.mocked(api.getObserver).mockResolvedValue(observer);
vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null });
mount({ pin: { kind: "observer", observerId, name: "Hill listener" }, view: "observer" });
await waitFor(() => expect(handlers.onUpgrade).toHaveBeenCalledWith(expect.objectContaining({ id: node.id }), observerId));
});
});
+77 -5
View File
@@ -16,6 +16,8 @@ vi.mock("../../../src/api/client", async (importOriginal) => ({
getNode: vi.fn(),
getNodeObservations: vi.fn(),
getPacketDetail: vi.fn(),
getObserverDirectoryPage: vi.fn(),
getObserver: vi.fn(),
}));
const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" };
@@ -23,6 +25,13 @@ const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey:
const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node;
const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 };
const actions = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() };
const observerId = "33333333-3333-4333-8333-333333333333";
const listener = { id: observerId, displayName: "Hill listener", observerType: "companion", iata: "YKF", status: "online", observationCount: 7 };
const directory = (items: unknown[]) => ({ items, hasMore: false, generatedAt: 0, windowStart: 0, windowEnd: 0, sort: "name", effectiveSort: "name", coverage: { status: "complete", expectedHours: 24, completeHours: 24, missingHours: 0, partialHours: 0 }, maxObservationCount: 7, observerTypes: [] }) as never;
const pickField = async (label: string) => {
fireEvent.click(screen.getByRole("button", { name: /Name|Public Key|Observer/ }));
fireEvent.click(await screen.findByRole("button", { name: label }));
};
const clients: QueryClient[] = [];
function mount(active = true) {
@@ -31,7 +40,7 @@ function mount(active = true) {
return render(<QueryClientProvider client={client}><MyAtlasPage {...actions} active={active} /></QueryClientProvider>);
}
function save(nodes = [pin], range = "24h") {
localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range }));
localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, pins: nodes.map((n) => ({ kind: "node", ...n })), range }));
}
const typeSearch = (value: string) => fireEvent.change(screen.getByRole("textbox", { name: "Find a node" }), { target: { value } });
const card = (name: string) => screen.findByRole("button", { name }).then((button) => button.closest("[data-atlas-card]") as HTMLElement);
@@ -65,6 +74,12 @@ describe("My Atlas", () => {
expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal));
});
it("still loads a version 1 collection", async () => {
localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes: [pin], range: "24h" }));
mount();
expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument();
});
it("keeps the search in a toolbar with the saved count chip beside the range picker, and lists matches only while typing", async () => {
save();
mount();
@@ -107,7 +122,7 @@ describe("My Atlas", () => {
expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled();
expect(screen.getByText("Showing the first 8 matches; refine the search for more.")).toBeInTheDocument();
expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1);
expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toHaveLength(1);
expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { name: "repeater", limit: 8 }, expect.any(AbortSignal));
view.unmount();
mount();
@@ -165,6 +180,63 @@ describe("My Atlas", () => {
expect(screen.getByText(/All 12 slots are in use/)).toBeInTheDocument();
});
it("searches observers by name and saves a node pin when the observer's key belongs to a node", async () => {
vi.mocked(api.getObserverDirectoryPage).mockResolvedValue(directory([listener]));
vi.mocked(api.getObserver).mockResolvedValue({ ...listener, publicKey: pin.publicKey } as never);
mount();
await pickField("Observer");
typeSearch("hill");
expect(await screen.findByText("Hill listener")).toBeInTheDocument();
expect(api.getObserverDirectoryPage).toHaveBeenCalledWith({ name: "hill", limit: 8 }, expect.anything());
fireEvent.click(screen.getByRole("button", { name: "Add Hill listener" }));
await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toEqual([{ kind: "node", id: pin.id, publicKey: pin.publicKey, name: "Renamed repeater", observerId }]));
expect(screen.getByRole("button", { name: "Hill listener is saved" })).toBeDisabled();
});
it("saves an observer-only pin when no node has the observer's key, and marks it saved by id", async () => {
vi.mocked(api.getObserverDirectoryPage).mockResolvedValue(directory([listener]));
vi.mocked(api.getObserver).mockResolvedValue({ ...listener, publicKey: "" } as never);
mount();
await pickField("Observer");
typeSearch("hill");
fireEvent.click(await screen.findByRole("button", { name: "Add Hill listener" }));
await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toEqual([{ kind: "observer", observerId, name: "Hill listener" }]));
expect(await screen.findByRole("button", { name: "Hill listener is saved" })).toBeDisabled();
});
it("links an observer result to an already pinned node instead of adding a twin", async () => {
vi.mocked(api.getObserverDirectoryPage).mockResolvedValue(directory([listener]));
vi.mocked(api.getObserver).mockResolvedValue({ ...listener, publicKey: pin.publicKey } as never);
save([pin]);
mount();
await pickField("Observer");
typeSearch("hill");
fireEvent.click(await screen.findByRole("button", { name: "Add Hill listener" }));
await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toEqual([{ kind: "node", ...pin, observerId }]));
expect(screen.getByRole("button", { name: "Hill listener is saved" })).toBeDisabled();
});
it("marks node results that are also observers", async () => {
vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, isObserver: true, observerId }], hasMore: false, nextCursor: null });
mount();
typeSearch("repeater");
expect(await screen.findByText("Observer")).toHaveClass("inline-flex");
});
it("keeps one card away from its default view at a time", async () => {
vi.mocked(api.getNode).mockImplementation(async (id) => ({ ...(id === second.id ? { ...node, ...second } : node), isObserver: true, observerId }));
vi.mocked(api.getObserver).mockResolvedValue({ id: observerId, status: "online", publicKey: pin.publicKey } as never);
save([pin, second]);
mount();
const first = await card("Renamed repeater");
fireEvent.click(within(first).getByRole("button", { name: "Observer" }));
expect(within(first).getByRole("button", { name: "Observer" })).toHaveAttribute("aria-pressed", "true");
const other = await card("Second");
fireEvent.click(within(other).getByRole("button", { name: "Heard by" }));
expect(within(first).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true");
expect(within(other).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "true");
});
it("keeps one heard-by open, saves reordering, removal and the range", async () => {
save([pin, second]);
mount();
@@ -173,9 +245,9 @@ describe("My Atlas", () => {
fireEvent.click(within(first).getByRole("button", { name: "Heard by" }));
fireEvent.click(within(next).getByRole("button", { name: "Heard by" }));
expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "false");
expect(within(next).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true");
expect(within(next).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "true");
fireEvent.keyDown(within(first).getByRole("button", { name: "Drag to reorder" }), { key: "ArrowRight" });
expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey);
expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].publicKey).toBe(second.publicKey);
fireEvent.click(screen.getByRole("button", { name: "3d" }));
expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true");
expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d");
@@ -247,7 +319,7 @@ describe("identity recovery", () => {
save();
mount();
await screen.findByRole("button", { name: "Renamed repeater" });
await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].id).toBe(second.id));
await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].id).toBe(second.id));
});
it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => {
vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey });
+53 -5
View File
@@ -1,22 +1,70 @@
import { describe, expect, it } from "vitest";
import { parseAtlas, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas";
import { describe, expect, it, vi } from "vitest";
import * as api from "../../../src/api/client";
import { parseAtlas, pinKey, resolveObserverNode, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas";
import type { NodeObservation } from "../../../src/features/nodes/types";
import type { Observer } from "../../../src/features/observers/types";
vi.mock("../../../src/api/client", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../src/api/client")>()),
getNodesPage: vi.fn(),
}));
describe("resolveObserverNode", () => {
it("resolves an observer to the node with the same key and to nothing otherwise", async () => {
const key = "ab".repeat(32);
const node = { id: "11111111-1111-4111-8111-111111111111", publicKey: key.toUpperCase(), name: "R1" };
vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, publicKey: "ab".repeat(31) + "ff" }, node], hasMore: false, nextCursor: null } as never);
await expect(resolveObserverNode({ publicKey: key } as Observer)).resolves.toMatchObject({ id: node.id });
expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: key, limit: 2 }, undefined);
await expect(resolveObserverNode({ publicKey: "" } as Observer)).resolves.toBeNull();
vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null } as never);
await expect(resolveObserverNode({ publicKey: key } as Observer)).resolves.toBeNull();
});
});
const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" };
const nodePin = { kind: "node", ...pin, publicKey: pin.publicKey.toLowerCase() };
const now = Date.UTC(2026, 8, 29, 12);
const report = (id: number, hours: number, extra: Partial<NodeObservation> = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra });
describe("saved Atlas", () => {
it("restores valid ordered full identities, normalizes keys and removes duplicates", () => {
expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] });
expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 2, range: "3d", pins: [nodePin, { ...nodePin, publicKey: "cd".repeat(32) }] });
});
it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => {
for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]);
for (const value of [null, "{", "[]", '{"version":3}', '{"version":1,"nodes":null}', '{"version":2,"pins":{}}']) expect(parseAtlas(value).pins).toEqual([]);
const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))];
const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes }));
expect(result.nodes).toHaveLength(ATLAS_LIMIT);
expect(result.pins).toHaveLength(ATLAS_LIMIT);
expect(result.range).toBe("24h");
});
it("migrates a version 1 collection to node pins in order and keeps the range", () => {
const raw = JSON.stringify({ version: 1, range: "3d", nodes: [
{ id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "One" },
{ id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Two" },
] });
expect(parseAtlas(raw)).toEqual({ version: 2, range: "3d", pins: [
{ kind: "node", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "One" },
{ kind: "node", id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Two" },
] });
});
it("round-trips version 2 pins of both kinds, keeps the observer hint, and drops unknown kinds", () => {
const pins = [
{ kind: "node", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "One", observerId: "33333333-3333-4333-8333-333333333333" },
{ kind: "observer", observerId: "44444444-4444-4444-8444-444444444444", name: "Listener" },
{ kind: "channel", id: "x", name: "Nope" },
{ kind: "observer", observerId: "not-a-uuid", name: "Bad" },
{ kind: "node", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Twin", observerId: "bad" },
];
expect(parseAtlas(JSON.stringify({ version: 2, range: "24h", pins }))).toEqual({ version: 2, range: "24h", pins: [pins[0], pins[1]] });
expect(pinKey(pins[0] as never)).toBe("ab".repeat(32));
expect(pinKey(pins[1] as never)).toBe("44444444-4444-4444-8444-444444444444");
});
it("dedupes observer pins by id and caps both kinds together at the limit", () => {
const observer = (n: number) => ({ kind: "observer", observerId: `${String(n).padStart(8, "0")}-0000-4000-8000-000000000000`, name: `L${n}` });
const pins = [observer(1), observer(1), ...Array.from({ length: 14 }, (_, i) => observer(i + 2))];
expect(parseAtlas(JSON.stringify({ version: 2, range: "24h", pins })).pins).toHaveLength(ATLAS_LIMIT);
});
});
describe("origin report sample", () => {
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { summarizeObserver, OBSERVER_RANGE } from "../../../src/features/atlas/observer-sample";
import type { ObserverActivity, ObserverTelemetry } from "../../../src/features/stats/types";
import type { Observer } from "../../../src/features/observers/types";
const HOUR = 3_600_000;
const now = Date.UTC(2026, 9, 13, 12, 30);
const point = (hoursAgo: number, observations: number | null, snrAvg: number | null) =>
({ t: now - hoursAgo * HOUR, observations, snrAvg, snrMin: null, rssiAvg: null, airtimeMs: null });
const activity = (points: ReturnType<typeof point>[]) => ({ range: "24h", interval: "1h", radio: null, payloadTypes: [], points }) as ObserverActivity;
const tele = (points: Partial<ObserverTelemetry["points"][number]>[]) => ({ range: "24h", interval: "1h", points: points.map((p) => ({
t: now, batteryMv: null, airtimeTxSecs: null, airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null, ...p })) }) as ObserverTelemetry;
describe("summarizeObserver", () => {
it("maps ranges to the server's duration strings", () => {
expect(OBSERVER_RANGE).toEqual({ "24h": "24h", "3d": "72h" });
});
it("bins packets heard per hour over the window, newest last, and sums them", () => {
const sample = summarizeObserver(activity([point(0, 3, 1), point(1, 5, 2), point(30, 99, 0)]), undefined, undefined, "24h", now);
expect(sample.counts).toHaveLength(24);
expect(sample.counts[23]).toBe(3);
expect(sample.counts[22]).toBe(5);
expect(sample.counts[0]).toBe(0);
expect(sample.heard).toBe(8);
});
it("weights the mean SNR by packets and leaves silent hours null", () => {
const sample = summarizeObserver(activity([point(0, 1, 10), point(1, 3, 2), point(2, 0, 7), point(3, null, null)]), undefined, undefined, "24h", now);
expect(sample.snr).toBeCloseTo(4);
expect(sample.snrHourly[23]).toBe(10);
expect(sample.snrHourly[21]).toBeNull();
expect(sample.counts[20]).toBeNull();
});
it("takes the latest noise floor and battery, ignoring all-zero rows", () => {
const sample = summarizeObserver(undefined, tele([
{ t: now - 2 * HOUR, noiseFloorDb: -118, batteryMv: 4050 },
{ t: now - HOUR, noiseFloorDb: 0, batteryMv: 0 },
{ t: now - 3 * HOUR, noiseFloorDb: -121, batteryMv: null },
]), undefined, "24h", now);
expect(sample.noise).toBe(-118);
expect(sample.battery).toBeCloseTo(4.05);
expect(sample.noiseHourly[20]).toBe(-121);
expect(sample.noiseHourly[21]).toBe(-118);
expect(sample.noiseHourly[22]).toBeNull();
});
it("treats a zero battery reading on an otherwise live row as no battery", () => {
const sample = summarizeObserver(undefined, tele([{ t: now - HOUR, batteryMv: 0, noiseFloorDb: -96, uptimeSeconds: 5 }]), undefined, "24h", now);
expect(sample.noise).toBe(-96);
expect(sample.battery).toBeNull();
expect(sample.batteryHourly[22]).toBeNull();
expect(sample.uptime).toBe(5);
});
it("prefers the observer record's uptime and gives nulls, not NaN, on empty input", () => {
const empty = summarizeObserver(undefined, undefined, undefined, "3d", now);
expect(empty).toMatchObject({ heard: null, snr: null, noise: null, battery: null, uptime: null });
expect(empty.counts).toHaveLength(72);
const sample = summarizeObserver(undefined, tele([{ uptimeSeconds: 10 }]), { uptimeSeconds: 99 } as Observer, "24h", now);
expect(sample.uptime).toBe(99);
});
});
@@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { act, renderHook } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import * as api from "../../../src/api/client";
import { ApiError } from "../../../src/api/client";
import { shouldRetryQuery } from "../../../src/api/rate-limit";
import { ATLAS_REFRESH_MS } from "../../../src/features/atlas/useAtlas";
import { useAtlasObserver } from "../../../src/features/atlas/useAtlasObserver";
import type { Observer } from "../../../src/features/observers/types";
vi.mock("../../../src/api/client", async (importOriginal) => ({
...(await importOriginal<typeof import("../../../src/api/client")>()),
getObserver: vi.fn(),
getObserverActivity: vi.fn(),
getObserverTelemetry: vi.fn(),
}));
const id = "33333333-3333-4333-8333-333333333333";
const observer = { id, status: "online", iata: "YKF", publicKey: "ab".repeat(32), firstSeen: 1, lastSeen: 2, observationCount: 5, brokers: [], uptimeSeconds: 400 } as Observer;
let client: QueryClient;
const wrapper = ({ children }: { children: ReactNode }) => <QueryClientProvider client={client}>{children}</QueryClientProvider>;
const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
beforeEach(() => {
vi.useFakeTimers();
client = new QueryClient({ defaultOptions: { queries: { retry: shouldRetryQuery } } });
vi.mocked(api.getObserver).mockResolvedValue(observer);
vi.mocked(api.getObserverActivity).mockResolvedValue({ range: "24h", interval: "1h", radio: null, payloadTypes: [], points: [{ t: Date.now(), observations: 4, snrAvg: 3, snrMin: null, rssiAvg: null, airtimeMs: null }] });
vi.mocked(api.getObserverTelemetry).mockResolvedValue({ range: "24h", interval: "1h", points: [] });
});
afterEach(() => {
client.clear();
vi.useRealTimers();
vi.clearAllMocks();
});
describe("useAtlasObserver", () => {
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);
expect(result.current.observer.data).toEqual(observer);
expect(api.getObserverActivity).not.toHaveBeenCalled();
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(result.current.sample.uptime).toBe(400);
});
it("polls open queries once a minute and asks for 72h at the 3d window", async () => {
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);
await tick(ATLAS_REFRESH_MS + 50);
expect(api.getObserverActivity).toHaveBeenCalledTimes(2);
expect(api.getObserver).toHaveBeenCalledTimes(2);
});
it("treats a 404 as an unsupported server: dashes, no retry, no further polling of that query", async () => {
vi.mocked(api.getObserverActivity).mockRejectedValue(new ApiError(404, "not_found", "no"));
const { result } = renderHook(() => useAtlasObserver(id, "24h", true, true), { wrapper });
await tick(10);
expect(result.current.unsupported).toBe(true);
expect(result.current.sample.heard).toBeNull();
await tick(ATLAS_REFRESH_MS + 50);
expect(api.getObserverActivity).toHaveBeenCalledTimes(1);
expect(api.getObserverTelemetry).toHaveBeenCalledTimes(2);
});
it("does nothing without an observer id", () => {
renderHook(() => useAtlasObserver(null, "24h", true, true), { wrapper });
expect(api.getObserver).not.toHaveBeenCalled();
});
});