mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-22 14:11:24 +00:00
Atlas: observer view, observer pins and observer search
This commit is contained in:
1 parent
33333b1a8d
commit
b2863816fa
13 files changed
+858
-144
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user