From b2863816fa957cbd270e2302f522595bfc6a4bbc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 14 Oct 2026 09:06:10 -0400 Subject: [PATCH] Atlas: observer view, observer pins and observer search --- src/features/atlas/AtlasCard.tsx | 196 +++++++++++++----- src/features/atlas/MyAtlasPage.tsx | 151 ++++++++++---- src/features/atlas/atlas.ts | 59 ++++-- src/features/atlas/observer-sample.ts | 81 ++++++++ src/features/atlas/useAtlas.ts | 11 +- src/features/atlas/useAtlasObserver.ts | 58 ++++++ src/i18n/locales/en.json | 17 ++ src/i18n/locales/fr.json | 17 ++ tests/features/atlas/AtlasCard.test.tsx | 131 ++++++++++-- tests/features/atlas/MyAtlasPage.test.tsx | 82 +++++++- tests/features/atlas/atlas.test.ts | 58 +++++- tests/features/atlas/observer-sample.test.ts | 64 ++++++ .../features/atlas/useAtlasObserver.test.tsx | 77 +++++++ 13 files changed, 858 insertions(+), 144 deletions(-) create mode 100644 src/features/atlas/observer-sample.ts create mode 100644 src/features/atlas/useAtlasObserver.ts create mode 100644 tests/features/atlas/observer-sample.test.ts create mode 100644 tests/features/atlas/useAtlasObserver.test.tsx diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 4336f2d..e5a776a 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -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; } -// One pinned node, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way. +const VIEW_LABEL: Record = { 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(); @@ -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(null); const [held, setHeld] = useState(); - const toggleHeard = () => { - if (!heardOpen) setHeld(body.current?.offsetHeight); - onHeardToggle(); + const changeView = (next: AtlasView) => { + if (view !== "heard") setHeld(body.current?.offsetHeight); + onViewChange(next); }; const title = ( {node && {node.nodeTypeName}} {node?.stale === false && {t("atlas.recent")}} {node?.stale === true && {t("atlas.stale")}} + {pin.kind === "observer" && observer?.observerType && {observer.observerType}} + {pin.kind === "observer" && status && ( + {t(status === "online" ? "atlas.online" : "atlas.offline")} + )} ); - // 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 = (
- + {views.length > 1 && ( + changeView(next as AtlasView)} + options={views.map((value) => ({ value, label: t(VIEW_LABEL[value]) }))} + /> + )} @@ -140,21 +174,29 @@ export function AtlasCard({
); + const retry = (refetch: () => Promise) => ( +

+ {t("common.loadFailed")}{" "} + +

+ ); + const dash = "—"; + return ( - {query.isPending &&

{t("common.loading")}

} - {query.isError && isNotFound(query.error) && ( + {pin.kind === "node" && query.isPending &&

{t("common.loading")}

} + {pin.kind === "node" && query.isError && isNotFound(query.error) && (

{t("nodeDetail.notFound")}

)} - {query.isError && !isNotFound(query.error) && ( -

- {t("common.loadFailed")}{" "} - -

+ {pin.kind === "node" && query.isError && !isNotFound(query.error) && retry(query.refetch)} + {pin.kind === "observer" && obs.observer.isPending &&

{t("common.loading")}

} + {pin.kind === "observer" && obs.observer.isError && isNotFound(obs.observer.error) && ( +

{t("atlas.observerGone")}

)} - {node && !heardOpen && ( + {pin.kind === "observer" && obs.observer.isError && !isNotFound(obs.observer.error) && retry(obs.observer.refetch)} + {view === "analytics" && node && (
)} - {node && heardOpen && ( + {view === "heard" && node && (
{!latest ? (

{t("atlas.noReports")}

@@ -242,6 +284,54 @@ export function AtlasCard({ )}
)} + {view === "observer" && observerId && (pin.kind === "observer" ? observer : node) && ( +
+
+ + + + {obs.sample.battery !== null ? ( + + ) : ( + + )} +
+
+ {status && {t(status === "online" ? "atlas.online" : "atlas.offline")}} + {observer?.lastStatusAt != null && ( + + {t("atlas.lastStatus")} + + )} + {radio && {radio}} + {obs.loading && {t("common.loading")}} + {obs.failed && {t("common.loadFailed")}} + + + +
+
+ )} ); } diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index f6e152c..86fe5fd 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -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(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({ + 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) => ( + + ); + const rowClass = "flex items-center justify-between gap-2 border-t border-border-subtle py-1.5 first:border-t-0"; return (
@@ -103,7 +155,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti - {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 })} {storageFailed && {t("atlas.storageFailed")}} @@ -135,37 +187,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti

) : results.data.items.length === 0 ? (

{t("common.noMatches")}

- ) : ( + ) : results.data.kind === "node" ? (
    {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 ( -
  • +
  • {label} {node.nodeTypeName} + {node.isObserver && {t("atlas.observerBadge")}} {formatHex(node.publicKey)} - + {addButton(pinned, label, () => add(node))} +
  • + ); + })} +
+ ) : ( +
    + {results.data.items.map((row) => { + const label = observerName(row); + const online = deriveObserverStatus(row) === "online"; + return ( +
  • + + + {label} + {row.observerType && {row.observerType}} + + {addButton(pinnedObserver(row.id), label, () => addObserver.mutate(row), addObserver.isPending)}
  • ); })}
)} + {addObserver.isError &&

{t("common.loadFailed")}

} {results.data?.hasMore && !hint && (

{t("atlas.refine", { limit: SEARCH_LIMIT })}

)} @@ -173,20 +233,21 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
)} - {saved.nodes.length === 0 ? ( + {saved.pins.length === 0 ? ( ) : (
- {saved.nodes.map((pin, index) => ( -
+ {saved.pins.map((pin, index) => ( +
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} /> diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index b6bc357..0ddf248 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -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; + 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 { + 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); diff --git a/src/features/atlas/observer-sample.ts b/src/features/atlas/observer-sample.ts new file mode 100644 index 0000000..5ce65c7 --- /dev/null +++ b/src/features/atlas/observer-sample.ts @@ -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 = { "24h": "24h", "3d": "72h" }; +export const OBSERVER_INTERVAL = "1h"; +const RANGE_HOURS: Record = { "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(hours).fill(0); + const snrHourly = Array(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(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, + }; +} diff --git a/src/features/atlas/useAtlas.ts b/src/features/atlas/useAtlas.ts index 5dfc31c..8601169 100644 --- a/src/features/atlas/useAtlas.ts +++ b/src/features/atlas/useAtlas.ts @@ -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, diff --git a/src/features/atlas/useAtlasObserver.ts b/src/features/atlas/useAtlasObserver.ts new file mode 100644 index 0000000..3664f51 --- /dev/null +++ b/src/features/atlas/useAtlasObserver.ts @@ -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)), + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 58dcca3..f6b447e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 086a497..9f303cf 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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" } diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index 9c100d6..3dbed82 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -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[0]> = {}) { return render( - + , ); } @@ -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( - + , ); 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( + + + , + ); + } + 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)); + }); }); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 6496a9b..189378b 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -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(); } 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 }); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts index e82e779..e7e99fb 100644 --- a/tests/features/atlas/atlas.test.ts +++ b/tests/features/atlas/atlas.test.ts @@ -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()), + 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 => ({ 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", () => { diff --git a/tests/features/atlas/observer-sample.test.ts b/tests/features/atlas/observer-sample.test.ts new file mode 100644 index 0000000..06c25bd --- /dev/null +++ b/tests/features/atlas/observer-sample.test.ts @@ -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[]) => ({ range: "24h", interval: "1h", radio: null, payloadTypes: [], points }) as ObserverActivity; +const tele = (points: Partial[]) => ({ 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); + }); +}); diff --git a/tests/features/atlas/useAtlasObserver.test.tsx b/tests/features/atlas/useAtlasObserver.test.tsx new file mode 100644 index 0000000..2675d44 --- /dev/null +++ b/tests/features/atlas/useAtlasObserver.test.tsx @@ -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()), + 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 }) => {children}; +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(); + }); +});