diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 3d40d53..54e9fb2 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,85 +84,119 @@ 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]) }))} + /> + )} - +
); + 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")}

@@ -231,24 +284,57 @@ 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.sample.battery !== null && obs.sample.uptime !== null && {t("atlas.uptime")} {formatUptime(obs.sample.uptime)}} + {obs.loading && {t("common.loading")}} + {obs.failed && {t("common.loadFailed")}} + + + +
+
+ )} ); } - -export function AtlasDragHandle({ handle }: { handle: Partial }) { - const { t } = useTranslation(); - return ( - - ); -} diff --git a/src/features/atlas/AtlasObserverCard.tsx b/src/features/atlas/AtlasObserverCard.tsx deleted file mode 100644 index 7bc2874..0000000 --- a/src/features/atlas/AtlasObserverCard.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import { getObserverActivity, getObserverTelemetry, isNotFound } from "../../api/client"; -import { ACTION_BUTTON_CLASS } from "../../components/action-button"; -import { Badge } from "../../components/Badge"; -import { InfoTip } from "../../components/InfoTip"; -import { Timestamp } from "../../components/Timestamp"; -import { useRateLimit } from "../../hooks/useRateLimit"; -import { useTick } from "../../hooks/useTick"; -import { formatBattery, formatCount, formatUptime } from "../../lib/formatters"; -import { observerNoiseFloor } from "../observers/observer-stats"; -import { deriveObserverStatus } from "../observers/observer-status"; -import { Card, StatCard } from "../stats/cards"; -import { useChartColors } from "../stats/chartTheme"; -import type { AtlasPin, AtlasRange } from "./atlas-storage"; -import { finite, latest, loadAtlasObserver, observerActivitySeries, observerTelemetrySeries } from "./observer"; -import type { ReactNode } from "react"; - -export function AtlasObserverCard({ pin, range, active, onRemove, onResolved, onViewObserver, reorder }: { - pin: AtlasPin; range: AtlasRange; active: boolean; onRemove: () => void; - onResolved: (id: string) => void; onViewObserver: (id: string) => void; reorder?: ReactNode; -}) { - const { t, i18n } = useTranslation(); - const colors = useChartColors(); - const now = useTick(60_000); - const limitedUntil = useRateLimit(); - const enabled = active && !(limitedUntil != null && now < limitedUntil); - const polling = { staleTime: 60_000, refetchInterval: 60_000, refetchIntervalInBackground: false, refetchOnWindowFocus: false, retry: false } as const; - const observer = useQuery({ ...polling, queryKey: ["atlas-observer", pin.publicKey || pin.id], queryFn: ({ signal }) => loadAtlasObserver(pin, signal), enabled }); - const data = observer.data; - const identityReady = enabled && !!data && !observer.isError; - const duration = range === "3d" ? "72h" : "24h"; - const activity = useQuery({ ...polling, queryKey: ["atlas-observer-activity", data?.id, range], queryFn: ({ signal }) => getObserverActivity(data!.id, duration, "1h", undefined, signal), enabled: identityReady }); - const telemetry = useQuery({ ...polling, queryKey: ["atlas-observer-telemetry", data?.id, range], queryFn: ({ signal }) => getObserverTelemetry(data!.id, duration, "1h", undefined, signal), enabled: identityReady }); - useEffect(() => { if (data && data.id !== pin.id) onResolved(data.id); }, [data, pin.id, onResolved]); - const points = observerTelemetrySeries(telemetry.data?.points ?? [], range, now); - const heard = observerActivitySeries(activity.data, range, now); - const counts = heard.map(p => finite(p.observations) ? p.observations : null); - const count = counts.some(finite) ? counts.reduce((total, n) => total + (n ?? 0), 0) : null; - const snr = heard.map(p => finite(p.snrAvg) ? p.snrAvg : null); - const rssi = heard.map(p => finite(p.rssiAvg) ? p.rssiAvg : null); - const battery = finite(data?.batteryLevel) ? data.batteryLevel : latest(points.batteryMv) == null ? null : latest(points.batteryMv)! / 1000; - const uptime = finite(data?.uptimeSeconds) ? data.uptimeSeconds : latest(points.uptimeSeconds); - const noise = data ? observerNoiseFloor(data) ?? latest(points.noiseFloorDb) : null; - const measure = (value: number | null, unit: string) => value == null ? "—" : `${value.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} ${unit}`; - const failed = observer.isError || activity.isError || telemetry.isError; - const retry = () => { void observer.refetch(); if (data && !observer.isError) { void activity.refetch(); void telemetry.refetch(); } }; - const name = data?.displayName || pin.name || pin.id.slice(0, 8); - return - - ◉{t("atlas.observerCard")} - {data && {t(deriveObserverStatus(data, now) === "online" ? "atlas.recent" : "atlas.stale")}} - } right={
{reorder}
}> - {observer.isPending &&

{t("common.loading")}

} - {failed &&

{t(observer.isError && isNotFound(observer.error) ? "atlas.observerMissing" : "atlas.observerPartial")}

} - {data && <> -
- v == null) ? "atlas.observerPartialCoverage" : `atlas.ranges.${range}`)} /> - v == null ? null : v / 1000)} accent={colors.series[2]!} interpolateGaps /> - - - - -
-
- {t("atlas.lastSeen")} {data.lastSeen > 0 ? : "—"} - -
- } -
; -} diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index dc6b124..86fe5fd 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getNodesPage, getObserver, getObserverDirectoryPage } from "../../api/client"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; @@ -12,20 +12,25 @@ 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, AtlasDragHandle, type AtlasActions } from "./AtlasCard"; -import { ATLAS_LIMIT, atlasPinKey, parseAtlas, type AtlasPin, 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"; -import { AtlasObserverCard } from "./AtlasObserverCard"; -import type { ObserverDirectoryItem } from "../observers/types"; const RANGES: AtlasRange[] = ["24h", "3d"]; const SEARCH_LIMIT = 8; const SEARCH_MIN = 2; -// Global by design: pins are 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(); @@ -33,10 +38,11 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [field, setField] = useState("name"); - const observerMode = field === "observer"; - const [addingObserver, setAddingObserver] = useState(null); - const [addError, setAddError] = useState(false); - 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); @@ -44,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 @@ -51,66 +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), - enabled: active && ready && !observerMode, + 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 observerSearch = useQuery({ - queryKey: ["atlas-observer-search", typed], - queryFn: ({ signal }) => getObserverDirectoryPage({ name: typed, sort: "name", limit: SEARCH_LIMIT }, signal), - enabled: active && ready && observerMode, - staleTime: 30_000, - }); - const matches = observerMode ? observerSearch : results; - const entries: { id: string; name: string; key: string; role: string; node?: NodeSummary; observer?: ObserverDirectoryItem }[] = observerMode - ? (observerSearch.data?.items ?? []).map(observer => ({ id: observer.id, name: observer.displayName || observer.id.slice(0, 8), key: observer.id, role: t("atlas.observerCard"), observer })) - : (results.data?.items ?? []).map(node => ({ id: node.id, name: node.name || formatHex(node.id), key: node.publicKey, role: node.nodeTypeName, node })); - const addObserver = async (row: ObserverDirectoryItem) => { - setAddingObserver(row.id); setAddError(false); - try { - const observer = await getObserver(row.id); - if (observer.id !== row.id) throw new Error("Observer identity changed"); - const pin: AtlasPin = { kind: "observer", id: observer.id, publicKey: observer.publicKey || "", name: observer.displayName || "" }; - if (!parseAtlas(JSON.stringify({ version: 2, cards: [pin] })).cards.length) throw new Error("Invalid observer identity"); - update(value => parseAtlas(JSON.stringify({ ...value, cards: [...value.cards, pin] }))); - } catch { setAddError(true); } - finally { setAddingObserver(null); } - }; - - const full = saved.cards.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.searchObservers") }, + { 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 = { kind: "node", id: node.id, publicKey: node.publicKey, name: node.name || "" }; - return parseAtlas(JSON.stringify({ ...value, cards: [...value.cards, 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 = (key: string) => - update((value) => ({ ...value, cards: value.cards.filter((pin) => atlasPinKey(pin) !== key) })); - const relink = (key: string, id: string) => - update((value) => ({ ...value, cards: value.cards.map((pin) => (atlasPinKey(pin) === key ? { ...pin, id } : pin)) })); const reorder = (from: number, to: number) => update((value) => { - const nodes = [...value.cards]; - const [moved] = nodes.splice(from, 1); - if (!moved || to < 0 || to >= value.cards.length) return value; - nodes.splice(to, 0, moved); - return { ...value, cards: 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.cards.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 (
@@ -125,13 +149,13 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti fields={fields} field={field} onFieldChange={changeField} - inputLabel={t(observerMode ? "atlas.observerSearch" : "atlas.search")} + inputLabel={t("atlas.search")} /> {!isMobile && } - {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.cards.length, limit: ATLAS_LIMIT })} + {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.pins.length, limit: ATLAS_LIMIT })} {storageFailed && {t("atlas.storageFailed")}} @@ -150,57 +174,83 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti {typed.length > 0 && (
- {addError &&

{t("atlas.observerAddFailed")}

} {hint ? (

{t(hint, { min: SEARCH_MIN })}

- ) : matches.isPending ? ( + ) : results.isPending ? (

{t("common.loading")}

- ) : matches.isError ? ( + ) : results.isError ? (

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

- ) : entries.length === 0 ? ( + ) : results.data.items.length === 0 ? (

{t("common.noMatches")}

+ ) : results.data.kind === "node" ? ( +
    + {results.data.items.map((node) => { + 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))} +
  • + ); + })} +
) : (
    - {entries.map((entry) => { - const pinned = saved.cards.some(pin => entry.observer ? pin.kind === "observer" && pin.id === entry.id : pin.kind !== "observer" && pin.publicKey === entry.key.toLowerCase()); - return
  • - {entry.name}{entry.role}{formatHex(entry.key)} - -
  • ; + {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)} +
  • + ); })}
)} - {matches.data?.hasMore && !hint && ( + {addObserver.isError &&

{t("common.loadFailed")}

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

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

)} {full && !hint &&

{t("atlas.limitReached", { limit: ATLAS_LIMIT })}

}
)} - {saved.cards.length === 0 ? ( + {saved.pins.length === 0 ? ( ) : (
- {saved.cards.map((pin, index) => ( -
- {pin.kind === "observer" ? remove(atlasPinKey(pin))} onResolved={id => relink(atlasPinKey(pin), id)} onViewObserver={actions.onViewObserver} reorder={} /> : ( +
+ setHeardKey((key) => (key === atlasPinKey(pin) ? null : atlasPinKey(pin)))} - onRemove={() => remove(atlasPinKey(pin))} - onResolved={(id) => relink(atlasPinKey(pin), 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-storage.ts b/src/features/atlas/atlas-storage.ts deleted file mode 100644 index c2b0938..0000000 --- a/src/features/atlas/atlas-storage.ts +++ /dev/null @@ -1,35 +0,0 @@ -export const ATLAS_KEY = "beacon-my-atlas-v2"; -export const ATLAS_LEGACY_KEY = "beacon-my-atlas-v1"; -export const ATLAS_LIMIT = 12; -export type AtlasRange = "24h" | "3d"; -export interface AtlasPin { id: string; publicKey: string; name: string; kind?: "node" | "observer" } -export interface SavedAtlas { version: 2; range: AtlasRange; cards: AtlasPin[] } -const UUID = /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i; -const KEY = /^[\da-f]{64}$/i; - -export const atlasPinKey = (pin: AtlasPin) => `${pin.kind ?? "node"}:${pin.publicKey || pin.id}`; - -// Read v1 without overwriting it: an older deployed frontend can still recover its node pins. -export function parseAtlas(raw: string | null): SavedAtlas { - const empty: SavedAtlas = { version: 2, range: "24h", cards: [] }; - try { - if (!raw || raw.length > 32_000) return empty; - const value = JSON.parse(raw); - const input = value?.version === 1 ? value.nodes : value?.version === 2 ? value.cards : null; - if (!Array.isArray(input)) return empty; - const cards: AtlasPin[] = []; - for (const item of input) { - if (!item || typeof item !== "object" || Array.isArray(item)) continue; - const kind = value.version === 1 ? "node" : item?.kind; - if (kind !== "node" && kind !== "observer") continue; - if (typeof item.id !== "string" || !(UUID.test(item.id) || (kind === "node" && item.id === ""))) continue; - if (kind === "observer" && item.id === "00000000-0000-0000-0000-000000000000") continue; - if (typeof item.publicKey !== "string" || !(KEY.test(item.publicKey) || (kind === "observer" && item.publicKey === ""))) continue; - const pin: AtlasPin = { kind, id: item.id.toLowerCase(), publicKey: item.publicKey.toLowerCase(), name: typeof item.name === "string" ? item.name.slice(0, 160) : "" }; - if (cards.some(card => atlasPinKey(card) === atlasPinKey(pin))) continue; - cards.push(pin); - if (cards.length === ATLAS_LIMIT) break; - } - return { version: 2, range: value.range === "3d" ? "3d" : "24h", cards }; - } catch { return empty; } -} diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index e64d571..1735b93 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -1,16 +1,67 @@ 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 { ATLAS_KEY, ATLAS_LEGACY_KEY, ATLAS_LIMIT, atlasPinKey, parseAtlas } from "./atlas-storage"; -export type { AtlasPin, AtlasRange, SavedAtlas } from "./atlas-storage"; -import type { AtlasPin, AtlasRange } from "./atlas-storage"; +export const ATLAS_KEY = "beacon-my-atlas-v2"; +export const ATLAS_LEGACY_KEY = "beacon-my-atlas-v1"; +export const ATLAS_LIMIT = 12; export const REPORT_LIMIT = 200; +export type AtlasRange = "24h" | "3d"; +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: 2, range: "24h", pins: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + 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: 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 { - if (pin.id) node = await getNode(pin.id, signal); + node = await getNode(pin.id, signal); } catch (error) { if (!isNotFound(error)) throw error; } diff --git a/src/features/atlas/observer-sample.ts b/src/features/atlas/observer-sample.ts new file mode 100644 index 0000000..1cfef4b --- /dev/null +++ b/src/features/atlas/observer-sample.ts @@ -0,0 +1,91 @@ +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; + partial?: boolean; + counts: (number | null)[]; + snrHourly: (number | null)[]; + snr: number | null; + noiseHourly: (number | null)[]; + noise: number | null; + batteryHourly: (number | null)[]; + battery: number | null; + uptime: number | null; + uptimeHourly: (number | null)[]; +} + +export function summarizeObserver( + activity: ObserverActivity | undefined, + telemetry: ObserverTelemetry | undefined, + observer: Observer | undefined, + range: AtlasRange, + now: number, +): ObserverSample { + const hours = RANGE_HOURS[range]; + const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); + // Slots count back from the tick, newest last; a bucket newer than the tick lands in the newest slot. + const age = (t: number) => Math.max(0, Math.floor((now - t) / HOUR)); + const bin = (t: number) => hours - 1 - age(t); + const inWindow = (t: number) => Number.isFinite(t) && t <= now && age(t) < hours; + + const counts = Array.from({ length: hours }, (_, i): number | null => { + if (!activity) return null; + const hour = Math.floor((now - (hours - 1 - i) * HOUR) / HOUR) * HOUR; + return activity.rawFrom != null && hour < activity.rawFrom && (activity.rolledUntil == null || hour >= activity.rolledUntil) ? null : 0; + }); + const snrHourly = Array(hours).fill(null); + let heard: number | null = counts.some(finite) ? 0 : null; + let weighted = 0; + let weight = 0; + for (const p of activity?.points ?? []) { + if (!inWindow(p.t)) continue; + const i = bin(p.t); + if (!finite(p.observations)) { + counts[i] = null; + continue; + } + counts[i] = (counts[i] ?? 0) + p.observations; + heard = (heard ?? 0) + p.observations; + if (finite(p.snrAvg) && 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 (finite(p[key])) out[bin(p.t)] = p[key]; + return out; + }; + const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => finite(p[key]))?.[key]; + // Mains-powered observers report 0 mV; a battery never reads zero, so that is "no battery". + const volts = (mv: number | null | undefined) => (finite(mv) && mv > 0 ? mv / 1000 : null); + const battery = observer?.batteryLevel == null ? volts(latest("batteryMv")) : finite(observer.batteryLevel) && observer.batteryLevel > 0 ? observer.batteryLevel : null; + const uptime = finite(observer?.uptimeSeconds) ? observer.uptimeSeconds : latest("uptimeSeconds"); + + return { + heard, + partial: activity !== undefined && counts.some(value => value === null), + counts, + snrHourly, + snr: weight ? weighted / weight : null, + noiseHourly: series("noiseFloorDb"), + noise: latest("noiseFloorDb") ?? null, + batteryHourly: series("batteryMv").map(volts), + battery, + uptime: uptime ?? null, + uptimeHourly: series("uptimeSeconds"), + }; +} diff --git a/src/features/atlas/observer.ts b/src/features/atlas/observer.ts deleted file mode 100644 index 1bc754a..0000000 --- a/src/features/atlas/observer.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { ApiError, getNodesPage, getObserver, isNotFound } from "../../api/client"; -import type { Observer } from "../observers/types"; -import { fillActivity } from "../stats/transforms"; -import type { ObserverActivity, TelemetryPoint } from "../stats/types"; -import type { AtlasPin, AtlasRange } from "./atlas-storage"; - -export const HOUR = 3_600_000; -export const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); -export const latest = (values: (number | null)[]) => values.findLast(finite) ?? null; - -export async function loadAtlasObserver(pin: AtlasPin, signal?: AbortSignal): Promise { - let observer: Observer | undefined; - try { observer = await getObserver(pin.id, signal); } - catch (error) { if (!isNotFound(error)) throw error; } - if (pin.publicKey && observer?.publicKey?.toLowerCase() !== pin.publicKey) { - const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); - const node = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey && row.observerId); - observer = node?.observerId ? await getObserver(node.observerId, signal) : undefined; - } - if (!observer || (pin.publicKey && observer.publicKey?.toLowerCase() !== pin.publicKey) || (!pin.publicKey && observer.id !== pin.id)) { - throw new ApiError(404, "not_found", "Saved observer identity unavailable"); - } - return observer; -} - -// Keep one actual sample per hour. Missing hours remain null; no zero fill or invented readings. -export function observerTelemetrySeries(points: TelemetryPoint[], range: AtlasRange, now: number) { - const hours = range === "3d" ? 72 : 24; - const start = Math.floor(now / HOUR) * HOUR - (hours - 1) * HOUR; - const fields = ["batteryMv", "uptimeSeconds", "noiseFloorDb"] as const; - const series = { batteryMv: Array(hours).fill(null), uptimeSeconds: Array(hours).fill(null), noiseFloorDb: Array(hours).fill(null) }; - for (const point of [...points].sort((a, b) => a.t - b.t)) { - if (!finite(point.t) || point.t < start || point.t > now) continue; - const slot = Math.floor((point.t - start) / HOUR); - for (const field of fields) if (finite(point[field])) series[field][slot] = point[field]; - } - return series; -} - -export function observerActivitySeries(data: ObserverActivity | undefined, range: AtlasRange, now: number) { - if (!data) return []; - const end = finite(data.windowEnd) ? data.windowEnd : now; - const start = end - (range === "3d" ? 72 : 24) * HOUR; - return fillActivity(data.points.filter(p => finite(p.t) && p.t >= start && p.t <= end), HOUR, { start, end }, data); -} 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..6c6bbd5 --- /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: ({ signal }) => getObserver(observerId!, signal), + 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: ({ signal }) => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined, signal), + enabled: series, + refetchInterval: (query) => pollUnlessMissing(query.state.error), + }); + const telemetry = useQuery({ + ...base, + queryKey: ["atlas-observer-telemetry", observerId, range], + queryFn: ({ signal }) => getObserverTelemetry(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined, signal), + 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.isLoading || telemetry.isLoading), + 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 a8a0578..42488bb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -957,10 +957,10 @@ "perDay": "{{value}}/d" }, "atlas": { - "addNodes": "Add nodes or observers", + "addNodes": "Add nodes", "matches": "Matches", "search": "Find a node", - "searchHelp": "Search all regions by node name, public-key prefix (hex, at least {{min}} characters), or observer name. Up to {{limit}} cards.", + "searchHelp": "Searches every region by name or public-key prefix (hex, at least {{min}} characters). Up to {{limit}} nodes.", "searchTooShort": "Type at least {{min}} characters.", "searchNotHex": "A public key prefix is hex only (0-9, a-f).", "savedCount_one": "{{count}} / {{limit}} saved", @@ -974,8 +974,8 @@ "saved": "Saved", "addName": "Add {{name}}", "savedName": "{{name}} is saved", - "emptyTitle": "No cards saved", - "emptyDescription": "Search above to add up to {{limit}} nodes or observers", + "emptyTitle": "No nodes saved", + "emptyDescription": "Search above to add up to {{limit}} nodes", "range": "Activity window", "ranges": { "24h": "24h", @@ -994,19 +994,26 @@ "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. Solid lines show readings; dashed lines connect missing hours as visual estimates, not measurements. Uptime gaps remain open.", + "observerUnsupported": "This server does not provide observer activity or telemetry.", + "observerGone": "This observer is no longer known to the server.", "dragToReorder": "Drag to reorder", "remove": "Remove", - "observerCard": "Observer", - "searchObservers": "Observers", - "observerSearch": "Find an observer", - "observerMissing": "This saved observer is unavailable. Its identity could not be verified.", - "observerPartial": "Some observer data could not be refreshed. Available samples are retained.", - "observerPartialCoverage": "Partial history", - "observerReports": "Packets heard", - "observerSnr": "Latest hourly SNR", - "observerRssi": "Latest hourly RSSI", - "observerHistoryHelp": "Solid lines show reported samples. Dashed lines connect across missing hours; they are visual estimates, not measurements. Uptime gaps remain open. Device readings are the latest available status. Packet totals cover the selected window; unread hours remain unknown.", - "observerAddFailed": "Could not add this observer. Try again." + "observerPartialCoverage": "Partial history" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 9e165a8..f9281d5 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -958,10 +958,10 @@ "perDay": "{{value}}/j" }, "atlas": { - "addNodes": "Ajouter des nœuds ou observateurs", + "addNodes": "Ajouter des nœuds", "matches": "Résultats", "search": "Trouver un nœud", - "searchHelp": "Recherchez dans toutes les régions par nom de nœud, préfixe de clé publique (hexadécimal, au moins {{min}} caractères) ou nom d’observateur. Jusqu’à {{limit}} cartes.", + "searchHelp": "Recherche dans toutes les régions par nom ou préfixe de clé publique (hexadécimal, au moins {{min}} caractères). Jusqu’à {{limit}} nœuds.", "searchTooShort": "Saisissez au moins {{min}} caractères.", "searchNotHex": "Un préfixe de clé publique est en hexadécimal (0-9, a-f).", "savedCount_one": "{{count}} / {{limit}} enregistré", @@ -976,8 +976,8 @@ "saved": "Enregistré", "addName": "Ajouter {{name}}", "savedName": "{{name}} est enregistré", - "emptyTitle": "Aucune carte enregistrée", - "emptyDescription": "Recherchez ci-dessus pour ajouter jusqu’à {{limit}} nœuds ou observateurs", + "emptyTitle": "Aucun nœud enregistré", + "emptyDescription": "Cherchez ci-dessus pour ajouter jusqu’à {{limit}} nœuds", "range": "Période d’activité", "ranges": { "24h": "24 h", @@ -997,19 +997,26 @@ "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. Les lignes pleines indiquent les mesures; les pointillés relient les heures manquantes à titre indicatif. Les interruptions de disponibilité restent visibles.", + "observerUnsupported": "Ce serveur ne fournit pas l’activité ni la télémétrie des observateurs.", + "observerGone": "Cet observateur n’est plus connu du serveur.", "dragToReorder": "Glisser pour réordonner", "remove": "Retirer", - "observerCard": "Observateur", - "searchObservers": "Observateurs", - "observerSearch": "Trouver un observateur", - "observerMissing": "Cet observateur enregistré est indisponible. Son identité n’a pas pu être vérifiée.", - "observerPartial": "Certaines données n’ont pas pu être actualisées. Les mesures disponibles sont conservées.", - "observerPartialCoverage": "Historique partiel", - "observerReports": "Paquets reçus", - "observerSnr": "Dernier SNR horaire", - "observerRssi": "Dernier RSSI horaire", - "observerHistoryHelp": "Les lignes pleines représentent les mesures. Les pointillés relient les heures manquantes : ce sont des estimations visuelles, pas des mesures. Les interruptions de disponibilité restent visibles. Les valeurs de l’appareil proviennent du dernier état disponible. Les totaux concernent la période sélectionnée; les heures non lues restent inconnues.", - "observerAddFailed": "Impossible d’ajouter cet observateur. Réessayez." + "observerPartialCoverage": "Historique partiel" } } } diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index 9c100d6..b0ecfd6 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 dBm")).toBeInTheDocument(); + expect(screen.getByText("4.12V")).toBeInTheDocument(); + expect(screen.getByText("Online")).toBeInTheDocument(); + expect(screen.getByText("915 MHz · SF11 · 250 kHz").closest(".flex-wrap")).toBeInTheDocument(); + 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/AtlasObserverCard.test.tsx b/tests/features/atlas/AtlasObserverCard.test.tsx deleted file mode 100644 index c124c36..0000000 --- a/tests/features/atlas/AtlasObserverCard.test.tsx +++ /dev/null @@ -1,81 +0,0 @@ -import { afterEach, beforeEach, expect, it, vi } from "vitest"; -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import * as api from "../../../src/api/client"; -import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; -import { ATLAS_KEY, ATLAS_LEGACY_KEY } from "../../../src/features/atlas/atlas-storage"; -import type { Observer, ObserverDirectoryPage } from "../../../src/features/observers/types"; -import type { ObserverActivity, ObserverTelemetry } from "../../../src/features/stats/types"; - -vi.mock("../../../src/api/client", async original => ({ ...(await original()), getObserver: vi.fn(), getObserverActivity: vi.fn(), getObserverTelemetry: vi.fn(), getObserverDirectoryPage: vi.fn(), getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn() })); -const pin = { kind: "observer", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Home observer" }; -const observer = { ...pin, displayName: pin.name, status: "online", lastStatusAt: Date.now(), lastSeen: Date.now(), batteryLevel: 4.12, uptimeSeconds: 3600 } as Observer; -const clients: QueryClient[] = []; -const view = vi.fn(); -function mount(active = true) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); - return render(); -} -beforeEach(() => { - vi.clearAllMocks(); localStorage.clear(); - vi.mocked(api.getObserver).mockResolvedValue(observer); - vi.mocked(api.getObserverActivity).mockResolvedValue({ range: "24h", interval: "1h", windowEnd: Date.now(), points: [{ t: Date.now(), observations: 8, snrAvg: 0, rssiAvg: -95, airtimeMs: null, snrMin: 0 }] } as ObserverActivity); - vi.mocked(api.getObserverTelemetry).mockResolvedValue({ range: "24h", interval: "1h", points: [] } as ObserverTelemetry); - vi.mocked(api.getObserverDirectoryPage).mockResolvedValue({ items: [observer], hasMore: false } as unknown as ObserverDirectoryPage); -}); -afterEach(() => { clients.splice(0).forEach(c => c.clear()); }); -it("restores an observer card, shows available history, changes range and opens observer detail", async () => { - localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, range: "24h", cards: [pin] })); - mount(); - expect(await screen.findByText("4.12V")).toBeInTheDocument(); - expect(await screen.findByText("0 dB")).toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: pin.name })); expect(view).toHaveBeenCalledWith(pin.id); - fireEvent.click(screen.getByRole("button", { name: "3d" })); - await waitFor(() => expect(api.getObserverActivity).toHaveBeenCalledWith(pin.id, "72h", "1h", undefined, expect.any(AbortSignal))); - expect(api.getObserverTelemetry).toHaveBeenCalledWith(pin.id, "72h", "1h", undefined, expect.any(AbortSignal)); -}); -it("does not turn missing device data or an unavailable history endpoint into zeros", async () => { - localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, cards: [pin] })); - vi.mocked(api.getObserver).mockResolvedValue({ ...observer, batteryLevel: undefined, uptimeSeconds: undefined }); - vi.mocked(api.getObserverTelemetry).mockRejectedValue(new api.ApiError(503, "busy", "unavailable")); - mount(); await screen.findByRole("alert"); - expect(screen.queryByText("0.00V")).not.toBeInTheDocument(); - expect(screen.getByText("0 dB")).toBeInTheDocument(); - expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(3); -}); -it("queries nothing for inactive saved observers", () => { - localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, cards: [pin] })); mount(false); - expect(api.getObserver).not.toHaveBeenCalled(); expect(api.getObserverActivity).not.toHaveBeenCalled(); expect(api.getObserverTelemetry).not.toHaveBeenCalled(); -}); -it("adds an observer by name without rewriting the legacy node store", async () => { - const original = JSON.stringify({ version: 1, range: "24h", nodes: [] }); localStorage.setItem(ATLAS_LEGACY_KEY, original); - mount(); - fireEvent.click(screen.getByRole("button", { name: /Name/ })); - fireEvent.click(screen.getByRole("button", { name: "Observers", exact: true })); - fireEvent.change(screen.getByRole("textbox", { name: "Find an observer" }), { target: { value: "Home" } }); - fireEvent.click(await screen.findByRole("button", { name: "Add Home observer" })); - await screen.findByText("4.12V"); - expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards).toEqual([pin]); - expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(original); - fireEvent.click(screen.getByRole("button", { name: "Remove" })); - await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards).toEqual([])); -}); - -it("preserves legacy nodes while reordering a mixed dashboard", async () => { - const node = { id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Repeater", nodeTypeName: "REPEATER", knownNeighborCount: 0, iatas: [] }; - const original = JSON.stringify({ version: 1, range: "24h", nodes: [node] }); - localStorage.setItem(ATLAS_LEGACY_KEY, original); - vi.mocked(api.getNode).mockResolvedValue(node as never); - vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false }); - mount(); await screen.findByRole("button", { name: "Repeater", exact: true }); - fireEvent.click(screen.getByRole("button", { name: /Name/ })); - fireEvent.click(screen.getByRole("button", { name: "Observers", exact: true })); - fireEvent.change(screen.getByRole("textbox", { name: "Find an observer" }), { target: { value: "Home" } }); - fireEvent.click(await screen.findByRole("button", { name: "Add Home observer" })); - await screen.findByText("4.12V"); - fireEvent.keyDown(screen.getAllByRole("button", { name: "Drag to reorder" })[1]!, { key: "ArrowLeft" }); - const saved = JSON.parse(localStorage.getItem(ATLAS_KEY)!); - expect(saved.cards.map((p: { kind: string }) => p.kind)).toEqual(["observer", "node"]); - expect(saved.cards[1].publicKey).toBe(node.publicKey); - expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(original); -}); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index a4d85af..4d17273 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -2,7 +2,7 @@ import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; -import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; +import { ATLAS_KEY, ATLAS_LEGACY_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; import * as api from "../../../src/api/client"; import { ApiError } from "../../../src/api/client"; import type { Node } from "../../../src/features/nodes/types"; @@ -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); @@ -53,6 +62,15 @@ afterEach(() => { }); describe("My Atlas", () => { + it("reads legacy saved nodes and retains their original storage when saving the new model", async () => { + const legacy = JSON.stringify({ version: 1, nodes: [pin], range: "24h" }); + localStorage.setItem(ATLAS_LEGACY_KEY, legacy); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].publicKey).toBe(pin.publicKey); + expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(legacy); + }); it("renders saved nodes as Signal-style cards and nothing else before a search", async () => { save(); mount(); @@ -65,11 +83,17 @@ 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(); await screen.findByRole("button", { name: "Renamed repeater" }); - const bar = screen.getByRole("toolbar", { name: "Add nodes or observers" }); + const bar = screen.getByRole("toolbar", { name: "Add nodes" }); expect(within(bar).getByRole("textbox", { name: "Find a node" })).toBeInTheDocument(); expect(within(bar).getByText("1 / 12 saved")).toHaveClass("rounded-sm"); expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument(); @@ -84,7 +108,7 @@ describe("My Atlas", () => { save(); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - const bar = screen.getByRole("toolbar", { name: "Add nodes or observers" }); + const bar = screen.getByRole("toolbar", { name: "Add nodes" }); expect(bar).not.toHaveClass("flex-wrap"); expect(within(bar).getByText("1/12")).toHaveClass("rounded-sm"); expect(within(bar).queryByText("1 / 12 saved")).not.toBeInTheDocument(); @@ -94,7 +118,7 @@ describe("My Atlas", () => { it("shows the empty state and queries nothing with no pins", () => { mount(); - expect(screen.getByText("No cards saved")).toBeInTheDocument(); + expect(screen.getByText("No nodes saved")).toBeInTheDocument(); expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodesPage).not.toHaveBeenCalled(); }); @@ -107,7 +131,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)!).cards).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 +189,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 +254,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)!).cards[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"); @@ -208,7 +289,7 @@ describe("My Atlas", () => { await screen.findByRole("button", { name: "Renamed repeater" }); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); - expect(screen.getByText("No cards saved")).toBeInTheDocument(); + expect(screen.getByText("No nodes saved")).toBeInTheDocument(); }); it("does not query a retained but inactive page", () => { @@ -247,7 +328,7 @@ describe("identity recovery", () => { save(); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards[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 27d5db6..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: 2, range: "3d", cards: [{ ...pin, kind: "node", publicKey: pin.publicKey.toLowerCase() }, { ...pin, kind: "node", 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).cards).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.cards).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..96a48aa --- /dev/null +++ b/tests/features/atlas/observer-sample.test.ts @@ -0,0 +1,89 @@ +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("does not fill missing endpoints or unread rollup hours with measured zeros", () => { + expect(summarizeObserver(undefined, undefined, undefined, "24h", now).counts.every(v => v === null)).toBe(true); + const sample = summarizeObserver({ ...activity([]), rawFrom: now - HOUR }, undefined, undefined, "24h", now); + expect(sample.counts[0]).toBeNull(); + expect(sample.counts.at(-1)).toBe(0); + expect(sample.partial).toBe(true); + }); + + it("shows an available status battery without inventing history", () => { + const sample = summarizeObserver(undefined, undefined, { batteryLevel: 4.17 } as Observer, "24h", now); + expect(sample.battery).toBe(4.17); + expect(sample.batteryHourly.every(v => v === null)).toBe(true); + }); + + it("excludes future and non-finite readings while preserving a real zero SNR and uptime reset", () => { + const sample = summarizeObserver(activity([point(-5, 999, 99), point(0, 2, 0)]), tele([ + { t: now + HOUR, batteryMv: 99999, noiseFloorDb: -1 }, + { t: now - HOUR, noiseFloorDb: -110, uptimeSeconds: 100 }, + { t: now, noiseFloorDb: -110, batteryMv: NaN, uptimeSeconds: 0 }, + ]), undefined, "24h", now); + expect(sample.heard).toBe(2); + expect(sample.snr).toBe(0); + expect(sample.battery).toBeNull(); + expect(sample.uptimeHourly.slice(-2)).toEqual([100, 0]); + }); + it("maps ranges to the server's duration strings", () => { + expect(OBSERVER_RANGE).toEqual({ "24h": "24h", "3d": "72h" }); + }); + + 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/observer.test.ts b/tests/features/atlas/observer.test.ts deleted file mode 100644 index 251fca1..0000000 --- a/tests/features/atlas/observer.test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import * as api from "../../../src/api/client"; -import { ATLAS_KEY, ATLAS_LEGACY_KEY, atlasPinKey, parseAtlas } from "../../../src/features/atlas/atlas-storage"; -import { HOUR, loadAtlasObserver, observerActivitySeries, observerTelemetrySeries } from "../../../src/features/atlas/observer"; -import type { Observer } from "../../../src/features/observers/types"; -import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types"; - -const id = "11111111-1111-4111-8111-111111111111"; -const key = "ab".repeat(32); -const pin = { id, publicKey: key, name: "Home", kind: "observer" as const }; -afterEach(() => vi.restoreAllMocks()); - -describe("mixed saved Atlas", () => { - it("migrates v1 node order into a separate v2 key, including pending preview identities", () => { - expect(ATLAS_KEY).not.toBe(ATLAS_LEGACY_KEY); - const original = { version: 1, range: "3d", nodes: [{ ...pin, kind: undefined }, { id: "", name: "Pending", publicKey: "cd".repeat(32) }] }; - const saved = parseAtlas(JSON.stringify({ ...original, nodes: [null, ...original.nodes, 7] })); - expect(saved).toEqual({ version: 2, range: "3d", cards: original.nodes.map(p => ({ ...p, kind: "node" })) }); - }); - it("allows node and observer views of one identity but deduplicates each kind", () => { - const saved = parseAtlas(JSON.stringify({ version: 2, cards: [{ ...pin, kind: "node" }, pin, { ...pin, publicKey: key.toUpperCase() }, { ...pin, kind: "other" }, { ...pin, id: "" }] })); - expect(saved.cards).toHaveLength(2); - expect(atlasPinKey(saved.cards[0]!)).not.toBe(atlasPinKey(saved.cards[1]!)); - }); - it("supports UUID-only observers, but rejects partial keys and nil observer IDs", () => { - const saved = parseAtlas(JSON.stringify({ version: 2, cards: [{ ...pin, publicKey: "" }, { ...pin, publicKey: "ab" }, { ...pin, id: "00000000-0000-0000-0000-000000000000" }] })); - expect(saved.cards).toEqual([{ ...pin, publicKey: "" }]); - }); -}); - -describe("observer identity and history", () => { - it("does not adopt a different observer when a saved UUID changes identity", async () => { - vi.spyOn(api, "getObserver").mockResolvedValue({ id, publicKey: "ff".repeat(32) } as Observer); - vi.spyOn(api, "getNodesPage").mockResolvedValue({ items: [], hasMore: false }); - await expect(loadAtlasObserver(pin)).rejects.toMatchObject({ status: 404 }); - }); - it("relocates only through the same full public key and validates the final observer", async () => { - const next = "22222222-2222-4222-8222-222222222222"; - vi.spyOn(api, "getObserver").mockRejectedValueOnce(new api.ApiError(404, "not_found", "gone")).mockResolvedValueOnce({ id: next, publicKey: key } as Observer); - vi.spyOn(api, "getNodesPage").mockResolvedValue({ items: [{ publicKey: key, observerId: next }] as never, hasMore: false }); - expect((await loadAtlasObserver(pin)).id).toBe(next); - }); - it("keeps zero, missing hours, and uptime resets distinct and excludes future samples", () => { - const now = 30 * HOUR; - const point = (t: number, value: number | null) => ({ t, batteryMv: value, noiseFloorDb: value, uptimeSeconds: value } as TelemetryPoint); - const series = observerTelemetrySeries([point(now - 2 * HOUR, 4000), point(now, 0), point(now + HOUR, 9999), point(now - 1, null)], "24h", now); - expect(series.batteryMv.slice(-3)).toEqual([4000, null, 0]); - expect(series.uptimeSeconds.slice(-3)).toEqual([4000, null, 0]); - expect(observerTelemetrySeries([], "3d", now).batteryMv).toHaveLength(72); - }); - it("retains unread activity hours as unknown rather than a measured zero", () => { - const end = 30 * HOUR; - const rows = observerActivitySeries({ windowEnd: end, range: "24h", interval: "1h", points: [], rawFrom: end - HOUR } as unknown as ObserverActivity, "24h", end); - expect(rows.find(p => p.t === end - 2 * HOUR)?.observations).toBeNull(); - expect(rows.find(p => p.t === end)?.observations).toBe(0); - }); -}); diff --git a/tests/features/atlas/useAtlasObserver.test.tsx b/tests/features/atlas/useAtlasObserver.test.tsx new file mode 100644 index 0000000..131bbba --- /dev/null +++ b/tests/features/atlas/useAtlasObserver.test.tsx @@ -0,0 +1,88 @@ +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("cancels all in-flight observer requests on unmount", async () => { + vi.mocked(api.getObserver).mockImplementation(() => new Promise(() => {})); + vi.mocked(api.getObserverActivity).mockImplementation(() => new Promise(() => {})); + vi.mocked(api.getObserverTelemetry).mockImplementation(() => new Promise(() => {})); + const view = renderHook(() => useAtlasObserver(id, "24h", true, true), { wrapper }); + await tick(10); + const signals = [vi.mocked(api.getObserver).mock.calls[0]![1]!, vi.mocked(api.getObserverActivity).mock.calls[0]![4]!, vi.mocked(api.getObserverTelemetry).mock.calls[0]![4]!]; + expect(signals.every(signal => !signal.aborted)).toBe(true); + view.unmount(); + expect(signals.every(signal => signal.aborted)).toBe(true); + }); + it("loads the observer right away but activity and telemetry only while the view is open", async () => { + const { result, rerender } = renderHook(({ open }) => useAtlasObserver(id, "24h", true, open), { wrapper, initialProps: { open: false } }); + await tick(10); + 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.any(AbortSignal)); + expect(api.getObserverTelemetry).toHaveBeenCalledWith(id, "24h", "1h", undefined, expect.any(AbortSignal)); + 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, expect.any(AbortSignal)); + 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(); + }); +});