From 7423db2c6803d16da9e6b869c039ba404c9a1869 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 2 Oct 2026 08:53:29 -0400 Subject: [PATCH 01/10] feat(atlas): add saved node cards on the 2.0 baseline --- .env.example | 2 +- docs/my-atlas.md | 62 ++++++++ src/App.tsx | 2 + src/api/client.ts | 14 +- src/components/BottomNav.tsx | 9 +- src/features/atlas/AtlasCard.tsx | 113 +++++++++++++++ src/features/atlas/MyAtlasPage.tsx | 71 ++++++++++ src/features/atlas/atlas.ts | 66 +++++++++ src/features/atlas/useSavedAtlas.ts | 29 ++++ src/features/nodes/types.ts | 1 + src/i18n/locales/en.json | 75 +++++++++- src/i18n/locales/fr.json | 75 +++++++++- src/lib/constants.ts | 2 +- tests/components/BottomNav.test.tsx | 11 ++ tests/features/atlas/MyAtlasPage.test.tsx | 164 ++++++++++++++++++++++ tests/features/atlas/atlas.test.ts | 39 +++++ tests/lib/constants.test.ts | 4 +- 17 files changed, 722 insertions(+), 17 deletions(-) create mode 100644 docs/my-atlas.md create mode 100644 src/features/atlas/AtlasCard.tsx create mode 100644 src/features/atlas/MyAtlasPage.tsx create mode 100644 src/features/atlas/atlas.ts create mode 100644 src/features/atlas/useSavedAtlas.ts create mode 100644 tests/features/atlas/MyAtlasPage.test.tsx create mode 100644 tests/features/atlas/atlas.test.ts diff --git a/.env.example b/.env.example index 50ce1659..d0e3351f 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ # VITE_MAP_ZOOM=3.2 # Hide tabs completely (comma list, case-insensitive). Unset = show all. -# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# Options: Packets,MyAtlas,Channels,Map,Nodes,Observers,Routes,Traces,Analytics # VITE_DISABLED_TABS=Map,Traces # Only offer these themes in the picker (comma list of theme ids). Unset = the default set. diff --git a/docs/my-atlas.md b/docs/my-atlas.md new file mode 100644 index 00000000..e3b6985e --- /dev/null +++ b/docs/my-atlas.md @@ -0,0 +1,62 @@ +# My Atlas + +Open **My Atlas** in the desktop tab row, or **More > My Atlas** on a phone. The +canonical URL is `?tab=MyAtlas`; this opens the visitor's own collection. It does +not publish someone else's saved nodes. + +Expand **Add nodes** to search all regions by name or hexadecimal public-key prefix and add up to twelve +nodes. **Manage card** moves or removes a card. The selected nodes, their order +and the 24-hour/3-day activity window are stored in this browser on this origin +under `beacon-my-atlas-v1`. There is no account or cross-device synchronization. +Clearing site data removes the collection. When browser storage is unavailable, +the page explains that edits may only last for the current visit. + +Cards use the current full node identity, not an ambiguous short path prefix. +Names refresh from the node record. If an old server ID disappears or points at +a different key, the page searches for the exact full key before loading reports. +An unavailable node stays in the collection and remains removable. + +## Reading the cards + +- **Received reports** are stored receptions of packets originating from the exact + node key, after server deduplication. The page loads the latest 200 reports per + node, then filters that sample to the selected rolling window. Counts are not + a total of all node traffic. The card flags when more reports are available. +- Each bar covers one hour of the rolling window. Blank bars mean no reports in + the loaded sample; they do not prove silence or packet loss. **Statistics & + chart values** exposes the numeric hourly values and sample definition. +- **Mean SNR / RSSI** average finite, available measurements from that sample. + The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR + with a real RSSI is included. These are last-hop measurements at the observers, + not a measurement of the origin's radio health. Meters include numbers and + accessible values; missing measurements display a dash. +- Freshness uses the server's `stale` verdict. It does not infer online/offline + status from the report count or invent a local threshold. +- **Heard by** lazily opens the reporting observers for the latest loaded packet + in the selected window. It is not lifetime reach or all observers of the node. + Repeated reports from the same observer are grouped. Observer buttons open + inspection; the packet action opens the exact packet and observed paths. +- **Open node** reuses Beacon's node inspection. Statistics also show existing + neighbour count, radio settings, default scope and full public key. + +## Cost and compatibility + +The page is lazy-loaded and uses native SVG/CSS, without a new chart dependency. +No query runs for an empty collection before a valid search. Search is debounced, +limited to eight results and rejects invalid key input before issuing a request. +Each saved card normally makes two requests (node plus 200-report page) per +minute while the page is active and the document is visible. Error refreshes stop +until Retry or a later visit. A changed server ID can require two extra identity +requests. A single expanded Heard by panel uses the existing packet-detail cache. +Requests consume TanStack Query's abort signal when the page leaves. + +No server schema, retention settings, MQTT behavior or authentication changes are +needed. Existing raw-retention limits still apply. Longer-lived analytics remain +separate. English and French labels ship together. Deployers can hide the page +with `VITE_DISABLED_TABS=MyAtlas`, following the existing tab configuration. + +The focused tests cover persistence, corrupt/blocked storage, cross-tab changes, +exact-key recovery, rename display, bounded/lazy loading, missing signals, +expired evidence, range selection, order/removal and both languages. Release QA +also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, +and the matching public source offer. diff --git a/src/App.tsx b/src/App.tsx index c2ae75ed..3103e915 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -35,6 +35,7 @@ import type { PacketDetail } from "./types/api"; // Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the // first time someone opens the Map tab instead of bloating the initial bundle. +const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage }))); const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView }))); // Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats. @@ -301,6 +302,7 @@ function AppInner() { }, []); const tabContent: Record = { + MyAtlas: , Packets: ( { - return request(`/packets/${packetHash}`); +export function getPacketDetail(packetHash: string, signal?: AbortSignal): Promise { + return request(`/packets/${packetHash}`, undefined, signal); } export function getIatas(): Promise { @@ -289,6 +289,7 @@ export function getNodesPage( supportsMultibyteTraces?: "true" | "false"; neighbors?: boolean; // include each node's neighborIds (?neighbors=true) }, + signal?: AbortSignal, ): Promise> { if (noIatas(iatas)) return Promise.resolve(emptyPage()); return request("/nodes", { @@ -301,7 +302,7 @@ export function getNodesPage( supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, - }); + }, signal); } export interface ObserverDirectoryRequest { @@ -361,18 +362,19 @@ export function getObserversPage( }); } -export function getNode(nodeId: string): Promise { - return request(`/nodes/${nodeId}`); +export function getNode(nodeId: string, signal?: AbortSignal): Promise { + return request(`/nodes/${nodeId}`, undefined, signal); } export function getNodeObservations( nodeId: string, params?: { cursor?: number; limit?: number }, + signal?: AbortSignal, ): Promise> { return request(`/nodes/${nodeId}/observations`, { cursor: params?.cursor, limit: params?.limit ?? DEFAULT_PAGE_SIZE, - }); + }, signal); } export function getNodeNeighbors(nodeId: string): Promise { diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 26bed734..4621efc7 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -5,13 +5,13 @@ import { ENABLED_TABS } from "../lib/constants"; import { touchClick } from "../lib/touch-click"; // Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet. -const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const; -const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics"] as const; // inline SVGs, 20px / 1.6 stroke to match the rest of the icons function Icon({ name }: { name: string }) { const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; switch (name) { + case "MyAtlas": + return ; case "Packets": return ( @@ -113,9 +113,8 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) { const { t } = useTranslation(); const [sheetOpen, setSheetOpen] = useState(false); - const enabled = new Set(ENABLED_TABS); - const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t)); - const overflow: string[] = OVERFLOW_TABS.filter((t) => enabled.has(t)); + const primary = ENABLED_TABS.slice(0, 4); + const overflow = ENABLED_TABS.slice(4); const overflowActive = overflow.includes(activeTab); const pick = (tab: string) => { diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx new file mode 100644 index 00000000..1ddd095a --- /dev/null +++ b/src/features/atlas/AtlasCard.tsx @@ -0,0 +1,113 @@ +import { useId } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getPacketDetail } from "../../api/client"; +import { Timestamp } from "../../components/Timestamp"; +import { useTick } from "../../hooks/useTick"; +import { loadAtlasNode, REPORT_LIMIT, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas"; + +export interface AtlasActions { + onViewNode: (id: string) => void; + onViewObserver: (id: string) => void; + onAnalyzePacket: (hash: string, observationId?: number) => void; +} + +function SignalMeter({ label, value, min, max, unit, color }: { label: string; value: number | null; min: number; max: number; unit: string; color: string }) { + const { t, i18n } = useTranslation(); + const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; + const level = value === null ? 0 : Math.round(16 * Math.max(0, Math.min(1, (value - min) / (max - min)))); + return
+
{label}{value === null ? "—" : text}
+
+ {Array.from({ length: 16 }, (_, index) =>
+
; +} + +export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemove, onMove, first, last, onViewNode, onViewObserver, onAnalyzePacket }: AtlasActions & { + pin: AtlasPin; range: AtlasRange; active: boolean; heardOpen: boolean; onHeardToggle: () => void; + onRemove: () => void; onMove: (direction: -1 | 1) => void; first: boolean; last: boolean; +}) { + const { t, i18n } = useTranslation(); + const now = useTick(60_000); + const id = useId(); + const query = useQuery({ queryKey: ["atlas-node", pin.publicKey], queryFn: ({ signal }) => loadAtlasNode(pin, signal), enabled: active, staleTime: 60_000, retry: false, + refetchInterval: q => q.state.error ? false : 60_000, refetchIntervalInBackground: false }); + const node = query.data?.node; + const sample = summarizeReports(query.data?.page.items ?? [], range, now); + const latest = sample.reports[0]; + const heard = useQuery({ queryKey: ["packet-detail", latest?.packetHash], queryFn: ({ signal }) => getPacketDetail(latest!.packetHash, signal), enabled: active && heardOpen && !!latest, staleTime: 30_000, retry: false }); + const detail = heard.data; + const correctOrigin = !detail || !detail.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey; + const observers = correctOrigin ? [...new Map([...(detail?.observations ?? [])].sort((a, b) => a.heardAt - b.heardAt).map(row => [row.observerId, row])).values()].sort((a, b) => b.heardAt - a.heardAt) : []; + const name = node?.name || pin.name || pin.publicKey.slice(0, 12).toUpperCase(); + const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); + const maximum = Math.max(1, ...sample.bins); + const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + const button = "min-h-11 rounded border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; + return
+
+
+
+
+

+ {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })} +
+
+
+
+ {query.isPending ?

{t("common.loading")}

: query.isError &&

{t(node ? "atlas.refreshFailed" : "atlas.nodeUnavailable")}

} + {node && <> +
+
{t("atlas.reports")}{number(sample.reports.length)}
+ + {sample.bins.map((count, index) => {time(sample.since + index * 3_600_000)}: {count})} + +
{t(`atlas.ranges.${range}`)}{t("atlas.now")}
+

{t("atlas.sample", { limit: REPORT_LIMIT })}

+
+
+ + +
+ {!sample.reports.length &&

{t("atlas.noReports")}

} +
+ + +
+ {heardOpen &&
+

{t("atlas.latestAdvert")}

+ {!latest ?

{t("atlas.noReports")}

: heard.isPending ?

{t("common.loading")}

: heard.isError || !correctOrigin ?

{t("atlas.heardUnavailable")}

: <> +

{t("atlas.observers", { count: observers.length })}

+
    {observers.map(row =>
  • {row.iata}
  • )}
+ + } +
} +
+ {t("atlas.statistics")} +
+

{t("atlas.definition")}

+

{t("atlas.signalDefinition")}

+ {query.data?.page.hasMore &&

{t("atlas.capped")}

} +
+
{t("atlas.hops")}
{sample.hops === null ? "—" : number(sample.hops)}
+
{t("atlas.neighbors")}
{number(node.knownNeighborCount)}
+
{t("atlas.radio")}
{node.radio || "—"}
+
{t("atlas.scope")}
{node.defaultScope || "—"}
+
+

{node.publicKey}

+
{t("atlas.chartValues")}
{sample.bins.map((count, index) => )}
{t("atlas.hour")}{t("atlas.reports")}
{time(sample.since + index * 3_600_000)}{count}
+

{t("atlas.updated")}

+
+
+ } +
+ {t("atlas.manage")} +
+
+
+
; +} diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx new file mode 100644 index 00000000..ed5beb47 --- /dev/null +++ b/src/features/atlas/MyAtlasPage.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getNodesPage } from "../../api/client"; +import { SelectDropdown } from "../../components/SelectDropdown"; +import { nodeSearchParams } from "../nodes/node-search"; +import { AtlasCard, type AtlasActions } from "./AtlasCard"; +import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas"; +import { useSavedAtlas } from "./useSavedAtlas"; + +export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) { + const { t } = useTranslation(); + const { saved, update, storageFailed } = useSavedAtlas(); + const [searchOpen, setSearchOpen] = useState(saved.nodes.length === 0); + const [text, setText] = useState(""); + const [field, setField] = useState("name"); + const [query, setQuery] = useState({ text: "", field: "name" }); + const [heardKey, setHeardKey] = useState(null); + useEffect(() => { const timer = setTimeout(() => setQuery({ text: text.trim(), field }), 300); return () => clearTimeout(timer); }, [text, field]); + const valid = query.text.length >= 2 && (query.field !== "pubkey" || /^[a-f\d]{2,64}$/i.test(query.text)); + const ready = query.text === text.trim() && query.field === field && valid; + const search = useQuery({ queryKey: ["atlas-search", query.field, query.text], queryFn: ({ signal }) => getNodesPage(undefined, { ...nodeSearchParams(query.field, query.text), limit: 8 }, signal), enabled: active && searchOpen && ready, staleTime: 30_000, retry: false }); + const full = saved.nodes.length >= ATLAS_LIMIT; + const empty = saved.nodes.length === 0; + return
+
+
+
+ +

{t("tabs.MyAtlas")}

{t("atlas.subtitle")}

+
+
+ {(["24h", "3d"] as AtlasRange[]).map(range => )} +
+
+
setSearchOpen(event.currentTarget.open)} className="rounded-xl border border-border bg-bg-surface px-4 py-2"> + {t("atlas.addNodes")} {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} +
+
+ + setText(event.target.value)} placeholder={t(field === "pubkey" ? "atlas.keyPlaceholder" : "atlas.namePlaceholder")} className="min-h-11 min-w-0 flex-1 rounded-lg border border-border bg-bg-base px-3 text-sm text-text-bright placeholder:text-text-muted" /> +
+
+

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

+ {ready &&
+ {search.isPending ?

{t("common.loading")}

: search.isError ?

{t("common.loadFailed")}

: <> + {!search.data?.items.length &&

{t("atlas.noMatches")}

} +
    {search.data?.items.map(node => { + const selected = saved.nodes.some(pin => pin.publicKey === node.publicKey.toLowerCase()); + const name = node.name || node.publicKey.slice(0, 12).toUpperCase(); + return
  • +

    {name}

    {node.publicKey.slice(0, 16).toUpperCase()} · {t(`atlas.roles.${node.nodeType}`)}

    + +
  • ; + })}
+ {search.data?.hasMore &&

{t("atlas.refine")}

} + } +
} +
+

{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}

+ {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
+ {saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { + const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; + if (from < 0 || to < 0 || to >= nodes.length) return value; + [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; + return { ...value, nodes }; + })} {...actions} />)} +
} +
+
; +} diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts new file mode 100644 index 00000000..7662b65e --- /dev/null +++ b/src/features/atlas/atlas.ts @@ -0,0 +1,66 @@ +import { getNode, getNodeObservations, getNodesPage } from "../../api/client"; +import type { Node, NodeObservation } from "../nodes/types"; + +export const ATLAS_KEY = "beacon-my-atlas-v1"; +export const ATLAS_LIMIT = 12; +export const REPORT_LIMIT = 200; +export type AtlasRange = "24h" | "3d"; +export interface AtlasPin { id: string; publicKey: string; name: string } +export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] } + +export function parseAtlas(raw: string | null): SavedAtlas { + const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty; + const nodes: AtlasPin[] = []; + for (const pin of value.nodes) { + if (typeof pin?.id !== "string" || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id) || + typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue; + const publicKey = pin.publicKey.toLowerCase(); + if (nodes.some(node => node.publicKey === publicKey)) continue; + nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" }); + if (nodes.length === ATLAS_LIMIT) break; + } + return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes }; + } catch { return empty; } +} + +// 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) { + let node: Node | undefined; + try { node = await getNode(pin.id, signal); } + catch (error) { if (!(error instanceof Error && "status" in error && error.status === 404)) throw error; } + if (node?.publicKey.toLowerCase() !== pin.publicKey) { + const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); + const match = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey); + if (!match) throw new Error("Atlas identity unavailable"); + node = await getNode(match.id, signal); + } + if (node.publicKey.toLowerCase() !== pin.publicKey) throw new Error("Atlas identity changed"); + const page = await getNodeObservations(node.id, { limit: REPORT_LIMIT }, signal); + return { node, page }; +} + +export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now: number) { + const hours = range === "3d" ? 72 : 24; + const since = now - hours * 3_600_000; + const seen = new Set(); + const reports = rows.filter(row => { + if (seen.has(row.id) || row.heardAt <= since || row.heardAt > now) return false; + seen.add(row.id); + return Number.isFinite(row.heardAt); + }).sort((a, b) => b.heardAt - a.heardAt || b.id - a.id); + const bins = Array(hours).fill(0); + for (const row of reports) { + const index = Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)); + bins[index] = (bins[index] ?? 0) + 1; + } + const signal = reports.filter(row => !(row.rssi === 0 && row.snr === 0)); + const mean = (values: (number | undefined)[]) => { + const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; + }; + return { reports, bins, since, snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; +} diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts new file mode 100644 index 00000000..f71dadbd --- /dev/null +++ b/src/features/atlas/useSavedAtlas.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from "react"; +import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; + +export function useSavedAtlas() { + const [initial] = useState(() => { + try { return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; } + catch { return { value: parseAtlas(null), failed: true }; } + }); + const [saved, setSaved] = useState(initial.value); + const [storageFailed, setStorageFailed] = useState(initial.failed); + const current = useRef(initial.value); + useEffect(() => { + const receive = (event: StorageEvent) => { + if (event.key !== ATLAS_KEY && event.key !== null) return; + current.current = parseAtlas(event.newValue); + setSaved(current.current); + }; + window.addEventListener("storage", receive); + return () => window.removeEventListener("storage", receive); + }, []); + const update = (change: (value: SavedAtlas) => SavedAtlas) => { + const next = change(current.current); + current.current = next; + setSaved(next); + try { localStorage.setItem(ATLAS_KEY, JSON.stringify(next)); setStorageFailed(false); } + catch { setStorageFailed(true); } + }; + return { saved, update, storageFailed }; +} diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index 40886a67..d95da86e 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -22,6 +22,7 @@ export interface NodeSummary { observerId?: string; // Server verdict against its configured local border union; absence is unknown/disabled. possiblyForeign?: boolean; + stale?: boolean; // server-configured freshness threshold; absent on older servers } export interface Node extends NodeSummary { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index f8648274..9ed728ac 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -13,7 +13,8 @@ "Observers": "Observers", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analytics" + "Analytics": "Analytics", + "MyAtlas": "My Atlas" }, "navigation": { "primary": "Primary", @@ -912,6 +913,78 @@ }, "units": { "perDay": "{{value}}/d" + }, + "atlas": { + "subtitle": "Your nodes, at a glance.", + "range": "Activity window", + "ranges": { + "24h": "24 hours", + "3d": "3 days" + }, + "addNodes": "Add nodes", + "searchBy": "Search", + "name": "Name", + "publicKey": "Public key", + "search": "Find a node to save", + "namePlaceholder": "Search by node name…", + "keyPlaceholder": "Public key or hex prefix…", + "savedCount": "{{count}} / {{limit}} saved", + "searchHelp": "Search all regions. Enter at least two characters; a public key uses hexadecimal characters.", + "limitReached": "All {{limit}} card spaces are in use. Remove a card to add another.", + "noMatches": "No matching nodes. Try a different name or key.", + "add": "+ Add", + "added": "Saved", + "addName": "Add {{name}}", + "addedName": "{{name}} is saved", + "refine": "Showing the first eight matches. Refine your search to find more.", + "localOnly": "Saved in this browser · All regions", + "storageFailed": "Browser saving is unavailable. Your changes work for this visit but may not be kept.", + "emptyTitle": "Build your own view of the mesh", + "emptyDescription": "Find a repeater, companion or other node above and add a card. Your selection and card order will be here when you return.", + "roles": { + "0": "Unknown", + "1": "Companion", + "2": "Repeater", + "3": "Room", + "4": "Sensor" + }, + "unavailable": "Unavailable", + "recent": "Recently seen", + "stale": "Not seen recently", + "freshnessUnknown": "Freshness unknown", + "refreshFailed": "Refresh failed; showing the last loaded sample.", + "nodeUnavailable": "This saved node could not be loaded. It may have expired or the server may be unavailable.", + "retry": "Retry", + "activity": "Received activity", + "reports": "Received reports", + "activityLabel": "{{count}} received reports in the loaded {{range}} sample. Hourly values are available under Statistics.", + "now": "Now", + "sample": "Loaded sample · up to {{limit}} recent reports", + "snr": "Mean SNR", + "rssi": "Mean RSSI", + "noReports": "No retained reports in this window. This does not establish whether the node is offline.", + "heardBy": "Heard by", + "openNode": "Open node", + "latestAdvert": "Observers of the latest loaded packet", + "heardUnavailable": "The packet details are unavailable or have expired. Try another retained packet from the node profile.", + "observers_one": "{{count}} reporting observer", + "observers_other": "{{count}} reporting observers", + "openAdvert": "Open packet and observed paths", + "statistics": "Statistics & chart values", + "definition": "Each report is one stored packet reception originating from this full node key, using server deduplication. These are sampled reports, not a total of all node traffic. Blank bars mean no loaded reports; older raw history may have expired.", + "signalDefinition": "Signal meters average available last-hop measurements at the receiving observers. They do not measure the originating node’s radio health. The hourly bars use a rolling window, ending now.", + "capped": "More reports exist than this card loads. Counts and averages cover only the loaded sample.", + "hops": "Mean hops", + "neighbors": "Known neighbours", + "radio": "Radio settings", + "scope": "Default scope", + "chartValues": "Hourly values", + "hour": "Hour starting", + "updated": "Sample loaded", + "manage": "Manage card", + "earlier": "Move earlier", + "later": "Move later", + "remove": "Remove card" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 03ee2a65..d9d5dde1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -13,7 +13,8 @@ "Observers": "Observateurs", "Routes": "Routes", "Traces": "Traces", - "Analytics": "Analyses" + "Analytics": "Analyses", + "MyAtlas": "Mon Atlas" }, "navigation": { "primary": "Navigation principale", @@ -948,6 +949,78 @@ }, "units": { "perDay": "{{value}}/j" + }, + "atlas": { + "subtitle": "Vos nœuds en un coup d’œil.", + "range": "Période d’activité", + "ranges": { + "24h": "24 heures", + "3d": "3 jours" + }, + "addNodes": "Ajouter des nœuds", + "searchBy": "Recherche", + "name": "Nom", + "publicKey": "Clé publique", + "search": "Trouver un nœud à enregistrer", + "namePlaceholder": "Rechercher par nom de nœud…", + "keyPlaceholder": "Clé publique ou préfixe hexadécimal…", + "savedCount": "{{count}} / {{limit}} enregistrés", + "searchHelp": "Recherchez dans toutes les régions. Saisissez au moins deux caractères ; une clé publique utilise des caractères hexadécimaux.", + "limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.", + "noMatches": "Aucun nœud correspondant. Essayez un autre nom ou une autre clé.", + "add": "+ Ajouter", + "added": "Enregistré", + "addName": "Ajouter {{name}}", + "addedName": "{{name}} est enregistré", + "refine": "Les huit premiers résultats sont affichés. Précisez votre recherche pour en trouver d’autres.", + "localOnly": "Enregistré dans ce navigateur · Toutes les régions", + "storageFailed": "L’enregistrement dans le navigateur est indisponible. Vos modifications fonctionnent pour cette visite, mais risquent de ne pas être conservées.", + "emptyTitle": "Créez votre propre vue du réseau", + "emptyDescription": "Cherchez un répéteur, un compagnon ou un autre nœud ci-dessus et ajoutez une fiche. Votre sélection et l’ordre des fiches seront conservés pour votre prochaine visite.", + "roles": { + "0": "Inconnu", + "1": "Compagnon", + "2": "Répéteur", + "3": "Salon", + "4": "Capteur" + }, + "unavailable": "Indisponible", + "recent": "Vu récemment", + "stale": "Pas vu récemment", + "freshnessUnknown": "Actualité inconnue", + "refreshFailed": "Échec de l’actualisation ; affichage du dernier échantillon chargé.", + "nodeUnavailable": "Ce nœud enregistré n’a pas pu être chargé. Il a peut-être expiré ou le serveur est indisponible.", + "retry": "Réessayer", + "activity": "Activité reçue", + "reports": "Réceptions", + "activityLabel": "{{count}} réceptions dans l’échantillon chargé de {{range}}. Les valeurs horaires sont disponibles sous Statistiques.", + "now": "Maintenant", + "sample": "Échantillon chargé · jusqu’à {{limit}} réceptions récentes", + "snr": "SNR moyen", + "rssi": "RSSI moyen", + "noReports": "Aucune réception conservée pour cette période. Cela ne permet pas de conclure que le nœud est hors ligne.", + "heardBy": "Entendu par", + "openNode": "Ouvrir le nœud", + "latestAdvert": "Observateurs du dernier paquet chargé", + "heardUnavailable": "Les détails du paquet sont indisponibles ou ont expiré. Essayez un autre paquet conservé depuis le profil du nœud.", + "observers_one": "{{count}} observateur ayant signalé le paquet", + "observers_other": "{{count}} observateurs ayant signalé le paquet", + "openAdvert": "Ouvrir le paquet et les chemins observés", + "statistics": "Statistiques et valeurs du graphique", + "definition": "Chaque réception correspond à un paquet provenant de cette clé de nœud complète, après déduplication par le serveur. Il s’agit d’un échantillon, pas du trafic total du nœud. Les barres vides indiquent l’absence de réceptions chargées ; l’historique brut plus ancien peut avoir expiré.", + "signalDefinition": "Les indicateurs font la moyenne des mesures disponibles du dernier saut aux observateurs récepteurs. Ils ne mesurent pas l’état de la radio du nœud d’origine. Les barres horaires couvrent une période glissante se terminant maintenant.", + "capped": "D’autres réceptions existent au-delà de celles chargées. Les nombres et moyennes portent uniquement sur l’échantillon chargé.", + "hops": "Sauts moyens", + "neighbors": "Voisins connus", + "radio": "Réglages radio", + "scope": "Portée par défaut", + "chartValues": "Valeurs horaires", + "hour": "Début de l’heure", + "updated": "Échantillon chargé", + "manage": "Gérer la fiche", + "earlier": "Déplacer avant", + "later": "Déplacer après", + "remove": "Retirer la fiche" } } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 317b56b4..1ea5bedc 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -23,7 +23,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000; export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; +export const TABS = ["MyAtlas", "Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; // Per-deployment config: read via runtimeEnv, so the Docker image's /config.js (written at container // start, see .build/) overrides the VITE_* build-time env. diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index 0c78aa5c..6fb104bc 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -4,6 +4,17 @@ import { BottomNav } from "../../src/components/BottomNav"; import i18n from "../../src/i18n"; describe("BottomNav", () => { + it("puts My Atlas first and preserves its canonical identifier in French", async () => { + const onTabChange = vi.fn(); + render(); + expect(screen.getAllByRole("tab")[0]).toHaveTextContent("My Atlas"); + await act(() => i18n.changeLanguage("fr")); + fireEvent.click(screen.getByRole("tab", { name: "Mon Atlas" })); + expect(onTabChange).toHaveBeenCalledWith("MyAtlas"); + fireEvent.click(screen.getByText("Plus")); + fireEvent.click(screen.getByRole("menuitem", { name: "Nœuds" })); + expect(onTabChange).toHaveBeenCalledWith("Nodes"); + }); it("translates an open More sheet without changing the selected tab identifier", async () => { const onTabChange = vi.fn(); render(); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx new file mode 100644 index 00000000..3c19fec6 --- /dev/null +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -0,0 +1,164 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen, 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 * as api from "../../../src/api/client"; +import type { Node } from "../../../src/features/nodes/types"; +import type { PacketDetail } from "../../../src/types/api"; +import i18n from "../../../src/i18n"; + +vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn(), getPacketDetail: vi.fn() })); +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; +const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Second" }; +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 clients: QueryClient[] = []; +function mount(active = true) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + return render(); +} +function save(nodes = [pin]) { localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range: "24h" })); } +beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null }); + vi.mocked(api.getNode).mockImplementation(async id => id === second.id ? { ...node, ...second } : node); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 7 }); + vi.mocked(api.getPacketDetail).mockResolvedValue({ packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [ + { observerId: "observer-a", observerName: "Home listener", id: 1, heardAt: 1, iata: "YKF" }, + { 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 PacketDetail); +}); +afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); + +describe("My Atlas", () => { + it("does not scan nodes or packets before a search or selection", () => { + mount(); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("adds a search result once, persists it across visits, and displays its current name", async () => { + const view = mount(); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "repeater" } }); + fireEvent.click(await screen.findByRole("button", { name: "Add Renamed repeater" })); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); + view.unmount(); mount(); + expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + }); + it("loads heard-by details only on demand, deduplicates observers and links to the evidence", async () => { + save(); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(await screen.findByText("2 reporting observers")).toBeInTheDocument(); + expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); + fireEvent.click(screen.getByRole("button", { name: "Home listener" })); expect(actions.onViewObserver).toHaveBeenCalledWith("observer-a"); + fireEvent.click(screen.getByRole("button", { name: "Open packet and observed paths" })); expect(actions.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); + fireEvent.click(screen.getByRole("button", { name: "Open node" })); expect(actions.onViewNode).toHaveBeenCalledWith(pin.id); + expect(screen.getByRole("meter", { name: "Mean SNR: 0 dB" })).toHaveAttribute("aria-valuenow", "0"); + }); + it("keeps only one heard-by section open and saves reordering, removal and the raw-history range", async () => { + save([pin, second]); mount(); + const first = await screen.findByRole("article", { name: "Renamed repeater" }); + const next = await screen.findByRole("article", { name: "Second" }); + 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-expanded", "false"); + fireEvent.click(within(first).getByText("Manage card")); + fireEvent.click(within(first).getByRole("button", { name: "Move later" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); + fireEvent.click(screen.getByRole("button", { name: "3 days" })); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d"); + fireEvent.click(within(first).getByRole("button", { name: "Remove card" })); + expect(screen.queryByRole("article", { name: "Renamed repeater" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /7 days|30 days/ })).not.toBeInTheDocument(); + }); + it("uses French labels and keeps working when browser writes fail", async () => { + save(); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); + await act(() => i18n.changeLanguage("fr")); mount(); + await screen.findByRole("article", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3 jours" })); + expect(screen.getByRole("button", { name: "3 jours" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/L’enregistrement dans le navigateur est indisponible/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Entendu par" })).toBeInTheDocument(); + }); + it("honours changes made in another tab and keeps corrupt storage from crashing the page", async () => { + localStorage.setItem(ATLAS_KEY, "{"); mount(); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: ATLAS_KEY, newValue: JSON.stringify({ version: 1, nodes: [pin], range: "3d" }) }))); + await screen.findByRole("article", { name: "Renamed repeater" }); + expect(screen.getByRole("button", { name: "3 days" })).toHaveAttribute("aria-pressed", "true"); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not query a retained but inactive page", () => { + save(); mount(false); + expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodeObservations).not.toHaveBeenCalled(); + }); + it("explains expired packet evidence", async () => { + save(); vi.mocked(api.getPacketDetail).mockRejectedValue(new Error("expired")); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + }); + it("keeps an unavailable node removable without rendering zeros as real readings", async () => { + save(); vi.mocked(api.getNode).mockRejectedValue(new Error("unavailable")); mount(); + await screen.findByText(/This saved node could not be loaded/); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByText("Manage card")); + fireEvent.click(screen.getByRole("button", { name: "Remove card" })); + expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); + }); + it("does not expose observer links for a different full origin identity", async () => { + save(); vi.mocked(api.getPacketDetail).mockResolvedValue({ originPubkey: second.publicKey, observations: [] } as unknown as PacketDetail); mount(); + fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); + await screen.findByText(/The packet details are unavailable or have expired/); + expect(screen.queryByRole("button", { name: "Open packet and observed paths" })).not.toBeInTheDocument(); + }); + it("aborts in-flight requests when leaving the page", () => { + save(); vi.mocked(api.getNode).mockImplementation(() => new Promise(() => {})); + const view = mount(); const signal = vi.mocked(api.getNode).mock.calls[0]![1]!; + expect(signal.aborted).toBe(false); + view.unmount(); expect(signal.aborted).toBe(true); + }); + it("shows missing signal values without meters for an empty retained sample", async () => { + save(); vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); mount(); + await screen.findByRole("button", { name: "Open node" }); + expect(screen.getByRole("img", { name: "Mean SNR: Unavailable" })).toBeInTheDocument(); + expect(screen.queryByRole("meter")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Heard by" })); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + }); + it("never queries an invalid public-key search as an unfiltered node list", async () => { + mount(); fireEvent.click(screen.getByRole("button", { name: /Search.*Name/ })); + fireEvent.click(screen.getByRole("option", { name: "Public key" })); + fireEvent.change(screen.getByRole("searchbox"), { target: { value: "not-a-key" } }); + await act(() => new Promise(resolve => setTimeout(resolve, 350))); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); + +describe("identity recovery", () => { + it("recovers a changed server ID using the exact full public key", async () => { + vi.mocked(api.getNode).mockRejectedValueOnce(Object.assign(new Error("missing"), { status: 404 })); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, id: second.id }], nextCursor: null, hasMore: false }); + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, id: second.id }); + const result = await loadAtlasNode(pin); + expect(result.node.id).toBe(second.id); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, undefined); + }); + it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => { + vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey }); + vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); + await expect(loadAtlasNode(pin)).rejects.toThrow("Atlas identity unavailable"); + expect(api.getNodeObservations).not.toHaveBeenCalled(); + vi.mocked(api.getNodesPage).mockClear(); vi.mocked(api.getNode).mockRejectedValueOnce(new Error("network")); + await expect(loadAtlasNode(pin)).rejects.toThrow("network"); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts new file mode 100644 index 00000000..35e1c013 --- /dev/null +++ b/tests/features/atlas/atlas.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { parseAtlas, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; +import type { NodeObservation } from "../../../src/features/nodes/types"; + +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" }; +const now = Date.UTC(2026, 8, 29, 12); +const report = (id: number, hours: number, extra: Partial = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra }); + +describe("saved Atlas", () => { + it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }); + }); + it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => { + for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]); + const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))]; + const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes })); + expect(result.nodes).toHaveLength(ATLAS_LIMIT); + expect(result.range).toBe("24h"); + }); +}); + +describe("origin report sample", () => { + it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { + const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; + const sample = summarizeReports(rows, "24h", now); + expect(sample.reports.map(r => r.id)).toEqual([1, 5, 2]); + expect(sample.bins.reduce((a, b) => a + b, 0)).toBe(3); + expect(sample.bins[23]).toBe(2); + expect(sample.bins[0]).toBe(1); + expect(summarizeReports(rows, "3d", now).reports).toHaveLength(4); + }); + it("does not turn missing signal into zero and averages real zero SNR samples", () => { + const sample = summarizeReports([report(1, 1), report(2, 2, { rssi: 0, snr: 0 }), report(3, 3, { rssi: -110, snr: 0, hopCount: 0 }), report(4, 4, { rssi: -90, snr: 10, hopCount: 4 }), report(5, 5, { rssi: NaN, snr: Infinity })], "24h", now); + expect(sample.snr).toBe(5); + expect(sample.rssi).toBe(-100); + expect(sample.hops).toBe(2); + expect(summarizeReports([], "24h", now).snr).toBeNull(); + }); +}); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 328fdd6e..538dd1cc 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "MyAtlas", "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "MyAtlas", "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", ]); }); From 5692b7760d365db4efa3144651b4e4686a53218a Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 9 Oct 2026 22:07:42 -0400 Subject: [PATCH 02/10] Keep dev1 Atlas compact and packet-only; publish isolated dev1 images --- .github/workflows/ci.yml | 2 +- .github/workflows/docker-publish.yml | 3 +- docs/my-atlas.md | 4 +- package-lock.json | 4 +- package.json | 2 +- src/components/Sparkline.tsx | 31 ++++++++++++++ src/features/atlas/AtlasCard.tsx | 50 +++++++++++------------ src/features/atlas/MyAtlasPage.tsx | 5 ++- src/features/atlas/atlas.ts | 5 ++- src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- tests/components/Sparkline.test.tsx | 21 ++++++++++ tests/features/atlas/MyAtlasPage.test.tsx | 10 +++++ tests/features/atlas/atlas.test.ts | 10 +++++ 14 files changed, 116 insertions(+), 37 deletions(-) create mode 100644 src/components/Sparkline.tsx create mode 100644 tests/components/Sparkline.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f5355c1b..8d2f8e46 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,7 @@ name: CI on: push: - branches: [main, dev] + branches: [main, dev, dev1] pull_request: jobs: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c5cd733e..497dd6bb 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,7 +2,7 @@ name: Build and Publish Docker Image on: push: - branches: [main, dev] + branches: [main, dev, dev1] tags: ["v*"] env: @@ -42,6 +42,7 @@ jobs: tags: | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=dev,enable=${{ github.ref == 'refs/heads/dev' }} + type=raw,value=dev1,enable=${{ github.ref == 'refs/heads/dev1' }} type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=sha diff --git a/docs/my-atlas.md b/docs/my-atlas.md index e3b6985e..ebd33932 100644 --- a/docs/my-atlas.md +++ b/docs/my-atlas.md @@ -28,7 +28,7 @@ An unavailable node stays in the collection and remains removable. - **Mean SNR / RSSI** average finite, available measurements from that sample. The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR with a real RSSI is included. These are last-hop measurements at the observers, - not a measurement of the origin's radio health. Meters include numbers and + not a measurement of the origin's radio health. Signal charts include numbers and accessible values; missing measurements display a dash. - Freshness uses the server's `stale` verdict. It does not infer online/offline status from the report count or invent a local threshold. @@ -60,3 +60,5 @@ exact-key recovery, rename display, bounded/lazy loading, missing signals, expired evidence, range selection, order/removal and both languages. Release QA also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, and the matching public source offer. + +This basic Atlas build uses packet observations only. It includes no battery, environmental or Collector telemetry integration. Dashed signal-chart segments indicate estimates across missing samples; empty hours are not measured zeroes. diff --git a/package-lock.json b/package-lock.json index 797f55b4..b8457bdd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.2", + "version": "2.0.3-dev1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.2", + "version": "2.0.3-dev1.1", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 137b014a..3091f4ef 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.2", + "version": "2.0.3-dev1.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx new file mode 100644 index 00000000..cdd9e96b --- /dev/null +++ b/src/components/Sparkline.tsx @@ -0,0 +1,31 @@ +import { useTranslation } from "react-i18next"; + +const SPARK_W = 120; +const SPARK_H = 24; + +export function Sparkline({ values, color, markPeak, times, gapMs }: { values: (number | null)[]; color: string; markPeak?: boolean; times?: number[]; gapMs?: number }) { + const { t } = useTranslation(); + const known = values.filter((v): v is number => v !== null && Number.isFinite(v)); + const max = known.length ? Math.max(...known) : 0, min = known.length ? Math.min(...known) : 0; + const range = max - min; + const timed = times?.length === values.length && times.every((v,i) => Number.isFinite(v) && (!i || v > times[i-1]!)) && times.at(-1)! > times[0]! ? times : null; + const x = (i: number) => timed ? 2 + (timed[i]! - timed[0]!) / (timed.at(-1)! - timed[0]!) * (SPARK_W - 4) : values.length > 1 ? 2 + i / (values.length - 1) * (SPARK_W - 4) : SPARK_W / 2; + const y = (v: number) => range ? SPARK_H - 3 - (v - min) / range * (SPARK_H - 6) : SPARK_H / 2; + const runs: string[][] = [], bridges: [number,number][] = []; + let previous = -1; + values.forEach((v,i) => { + if (v === null || !Number.isFinite(v)) return; + const gap = previous >= 0 && (i > previous + 1 || !!(timed && gapMs && timed[i]! - timed[previous]! > gapMs)); + if (previous < 0 || gap) runs.push([]); + if (gap) bridges.push([previous,i]); + runs.at(-1)!.push(`${x(i)},${y(v)}`); previous = i; + }); + const peak = values.indexOf(max); + return + {t("charts.gapLegend")} + {known.length === 0 && } + {bridges.map(([a,b]) => )} + {runs.map(run => run.length > 1 ? : )} + {markPeak && known.length > 0 && } + ; +} diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 1ddd095a..bc0cd36f 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -1,3 +1,4 @@ +import { Sparkline } from "../../components/Sparkline"; import { useId } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; @@ -12,15 +13,12 @@ export interface AtlasActions { onAnalyzePacket: (hash: string, observationId?: number) => void; } -function SignalMeter({ label, value, min, max, unit, color }: { label: string; value: number | null; min: number; max: number; unit: string; color: string }) { +function SignalMeter({ label, value, values, min, max, unit, color }: { label: string; value: number | null; values: (number|null)[]; min: number; max: number; unit: string; color: string }) { const { t, i18n } = useTranslation(); const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; - const level = value === null ? 0 : Math.round(16 * Math.max(0, Math.min(1, (value - min) / (max - min)))); - return
-
{label}{value === null ? "—" : text}
-
- {Array.from({ length: 16 }, (_, index) =>
+ return
+
{label}{value===null ? "—" : text}
+
; } @@ -44,14 +42,14 @@ export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemo const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); const maximum = Math.max(1, ...sample.bins); const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); - const button = "min-h-11 rounded border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; - return
-
-
-
+ const button = "min-h-8 rounded-sm border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; + return
+
+
+
-

- {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })} +

+ {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })}
-
setSearchOpen(event.currentTarget.open)} className="rounded-xl border border-border bg-bg-surface px-4 py-2"> +
setSearchOpen(event.currentTarget.open)} className="rounded-sm border border-border bg-bg-surface px-4 py-2"> {t("atlas.addNodes")} {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })}
@@ -58,7 +58,8 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
}

{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}

- {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
+

{t("charts.gapLegend")}

+ {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
{saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; if (from < 0 || to < 0 || to >= nodes.length) return value; diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index 7662b65e..d5074f8d 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -62,5 +62,8 @@ export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; }; - return { reports, bins, since, snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; + const hourly = (field: "snr" | "rssi") => bins.map((_, index) => mean(signal + .filter(row => Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)) === index) + .map(row => row[field]))); + return { reports, bins, since, snrBins: hourly("snr"), rssiBins: hourly("rssi"), snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 9ed728ac..b258d6cd 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -852,7 +852,8 @@ }, "charts": { "uniquePackets": "Unique packets", - "busy": "Busy" + "busy": "Busy", + "gapLegend": "Solid lines and dots show readings. Dashed lines estimate between missing samples." }, "packetList": { "pathSearchInvalid": "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4).", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index d9d5dde1..dd315ce6 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -886,7 +886,8 @@ }, "charts": { "uniquePackets": "Paquets uniques", - "busy": "Occupation" + "busy": "Occupation", + "gapLegend": "Traits pleins et points : mesures. Tirets : estimation entre des données manquantes." }, "packetList": { "pathSearchInvalid": "Saisissez des hashs de saut complets de même longueur, séparés par des espaces, des virgules ou → (p. ex. 7f a4).", diff --git a/tests/components/Sparkline.test.tsx b/tests/components/Sparkline.test.tsx new file mode 100644 index 00000000..fbf13de2 --- /dev/null +++ b/tests/components/Sparkline.test.tsx @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { render } from "@testing-library/react"; +import { Sparkline } from "../../src/components/Sparkline"; + +describe("sparkline evidence", () => { + it("draws isolated measured zeros and dashed bridges, without changing the input", () => { + const values = [null, 0, null, 4, 5, null]; + const { container } = render(); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); + expect(values).toEqual([null, 0, null, 4, 5, null]); + }); + it("marks a long time gap and a single reading, without extrapolating measured points", () => { + const { container, rerender } = render(); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + rerender(); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(0); + }); +}); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 3c19fec6..210f35c1 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -34,6 +34,16 @@ beforeEach(() => { afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); describe("My Atlas", () => { + it("shows packet activity and signal charts without telemetry panels", async () => { + save(); mount(); + const card = await screen.findByRole("article", { name: "Renamed repeater" }); + await within(card).findByRole("button", { name: "Open node" }); + expect(within(card).getByRole("img", { name: /received reports/i })).toBeInTheDocument(); + expect(within(card).getByRole("meter", { name: "Mean SNR: 0 dB" })).toBeInTheDocument(); + expect(within(card).queryByText(/battery|temperature|humidity|telemetry/i)).not.toBeInTheDocument(); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + }); + it("does not scan nodes or packets before a search or selection", () => { mount(); expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts index 35e1c013..cee540a8 100644 --- a/tests/features/atlas/atlas.test.ts +++ b/tests/features/atlas/atlas.test.ts @@ -20,6 +20,16 @@ describe("saved Atlas", () => { }); describe("origin report sample", () => { + it("aligns signal charts with the activity window and preserves missing hours", () => { + const sample = summarizeReports([report(1, 0, { snr: 0, rssi: -100 }), report(2, 2, { snr: 8, rssi: -90 }), report(3, 24, { snr: 20, rssi: -70 })], "24h", now); + expect(sample.snrBins[23]).toBe(0); + expect(sample.rssiBins[23]).toBe(-100); + expect(sample.snrBins[22]).toBe(8); + expect(sample.snrBins[21]).toBeNull(); + expect(sample.snrBins[0]).toBeNull(); + expect(sample.bins[23]).toBe(1); + }); + it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; const sample = summarizeReports(rows, "24h", now); From 466c7243a84352640fd8794599cfc685323ad440 Mon Sep 17 00:00:00 2001 From: n30nex Date: Fri, 9 Oct 2026 22:46:22 -0400 Subject: [PATCH 03/10] Prepare dev1 Atlas with upstream route search updates --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index b8457bdd..625af155 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.3-dev1.1", + "version": "2.0.3-dev1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.3-dev1.1", + "version": "2.0.3-dev1.2", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 3091f4ef..0ace9232 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.3-dev1.1", + "version": "2.0.3-dev1.2", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { From b427282b30f8ca730b5971700c426aab3a3113f0 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 12 Oct 2026 13:18:24 -0400 Subject: [PATCH 04/10] Rebuild My Atlas on the shared Analytics cards (2.0.5) Atlas cards now use the Signal tab StatCard tiles, shared Card, badges, action buttons, IATA chips and search bar. Polling moves to TanStack Query with the app retry policy, pauses while rate-limited, and recovers a pin whose server ID changed by its full public key. Search results show a key prefix and explain short or non-hex input; heard-by states which packet it counts. Sparkline component removed in favour of the stats one. --- .env.example | 2 +- docs/my-atlas.md | 64 ---- package-lock.json | 4 +- package.json | 2 +- src/App.tsx | 4 +- src/api/rate-limit.ts | 4 + src/components/BottomNav.tsx | 8 +- src/components/Sparkline.tsx | 31 -- src/features/atlas/AtlasCard.tsx | 312 ++++++++++++------ src/features/atlas/MyAtlasPage.tsx | 227 ++++++++++--- src/features/atlas/atlas.ts | 101 ++++-- src/features/atlas/useAtlas.ts | 29 ++ src/features/atlas/useSavedAtlas.ts | 15 +- .../observers/useObserverDirectory.ts | 8 +- src/features/stats/SignalTab.tsx | 4 +- src/features/stats/cards.tsx | 6 +- src/features/stats/signal.ts | 3 + src/i18n/locales/en.json | 101 ++---- src/i18n/locales/fr.json | 101 ++---- src/lib/constants.ts | 2 +- tests/api/rate-limit.test.ts | 9 + tests/components/BottomNav.test.tsx | 11 +- tests/components/Sparkline.test.tsx | 21 -- tests/features/atlas/AtlasCard.test.tsx | 165 +++++++++ tests/features/atlas/MyAtlasPage.test.tsx | 260 +++++++++------ tests/features/atlas/atlas.test.ts | 69 +++- tests/features/atlas/useAtlas.test.tsx | 131 ++++++++ tests/features/stats/cards.test.tsx | 13 +- tests/features/stats/signal.test.ts | 12 +- tests/lib/constants.test.ts | 4 +- 30 files changed, 1146 insertions(+), 577 deletions(-) delete mode 100644 docs/my-atlas.md delete mode 100644 src/components/Sparkline.tsx create mode 100644 src/features/atlas/useAtlas.ts delete mode 100644 tests/components/Sparkline.test.tsx create mode 100644 tests/features/atlas/AtlasCard.test.tsx create mode 100644 tests/features/atlas/useAtlas.test.tsx diff --git a/.env.example b/.env.example index d0e3351f..d6003cf6 100644 --- a/.env.example +++ b/.env.example @@ -13,7 +13,7 @@ # VITE_MAP_ZOOM=3.2 # Hide tabs completely (comma list, case-insensitive). Unset = show all. -# Options: Packets,MyAtlas,Channels,Map,Nodes,Observers,Routes,Traces,Analytics +# Options: Packets,Channels,Map,Nodes,Observers,Routes,Traces,Analytics,MyAtlas # VITE_DISABLED_TABS=Map,Traces # Only offer these themes in the picker (comma list of theme ids). Unset = the default set. diff --git a/docs/my-atlas.md b/docs/my-atlas.md deleted file mode 100644 index ebd33932..00000000 --- a/docs/my-atlas.md +++ /dev/null @@ -1,64 +0,0 @@ -# My Atlas - -Open **My Atlas** in the desktop tab row, or **More > My Atlas** on a phone. The -canonical URL is `?tab=MyAtlas`; this opens the visitor's own collection. It does -not publish someone else's saved nodes. - -Expand **Add nodes** to search all regions by name or hexadecimal public-key prefix and add up to twelve -nodes. **Manage card** moves or removes a card. The selected nodes, their order -and the 24-hour/3-day activity window are stored in this browser on this origin -under `beacon-my-atlas-v1`. There is no account or cross-device synchronization. -Clearing site data removes the collection. When browser storage is unavailable, -the page explains that edits may only last for the current visit. - -Cards use the current full node identity, not an ambiguous short path prefix. -Names refresh from the node record. If an old server ID disappears or points at -a different key, the page searches for the exact full key before loading reports. -An unavailable node stays in the collection and remains removable. - -## Reading the cards - -- **Received reports** are stored receptions of packets originating from the exact - node key, after server deduplication. The page loads the latest 200 reports per - node, then filters that sample to the selected rolling window. Counts are not - a total of all node traffic. The card flags when more reports are available. -- Each bar covers one hour of the rolling window. Blank bars mean no reports in - the loaded sample; they do not prove silence or packet loss. **Statistics & - chart values** exposes the numeric hourly values and sample definition. -- **Mean SNR / RSSI** average finite, available measurements from that sample. - The zero-RSSI/zero-SNR unavailable sentinel is excluded; a measured zero SNR - with a real RSSI is included. These are last-hop measurements at the observers, - not a measurement of the origin's radio health. Signal charts include numbers and - accessible values; missing measurements display a dash. -- Freshness uses the server's `stale` verdict. It does not infer online/offline - status from the report count or invent a local threshold. -- **Heard by** lazily opens the reporting observers for the latest loaded packet - in the selected window. It is not lifetime reach or all observers of the node. - Repeated reports from the same observer are grouped. Observer buttons open - inspection; the packet action opens the exact packet and observed paths. -- **Open node** reuses Beacon's node inspection. Statistics also show existing - neighbour count, radio settings, default scope and full public key. - -## Cost and compatibility - -The page is lazy-loaded and uses native SVG/CSS, without a new chart dependency. -No query runs for an empty collection before a valid search. Search is debounced, -limited to eight results and rejects invalid key input before issuing a request. -Each saved card normally makes two requests (node plus 200-report page) per -minute while the page is active and the document is visible. Error refreshes stop -until Retry or a later visit. A changed server ID can require two extra identity -requests. A single expanded Heard by panel uses the existing packet-detail cache. -Requests consume TanStack Query's abort signal when the page leaves. - -No server schema, retention settings, MQTT behavior or authentication changes are -needed. Existing raw-retention limits still apply. Longer-lived analytics remain -separate. English and French labels ship together. Deployers can hide the page -with `VITE_DISABLED_TABS=MyAtlas`, following the existing tab configuration. - -The focused tests cover persistence, corrupt/blocked storage, cross-tab changes, -exact-key recovery, rename display, bounded/lazy loading, missing signals, -expired evidence, range selection, order/removal and both languages. Release QA -also checks real Pi data, phone/desktop layout, keyboard controls, reload/Back, -and the matching public source offer. - -This basic Atlas build uses packet observations only. It includes no battery, environmental or Collector telemetry integration. Dashed signal-chart segments indicate estimates across missing samples; empty hours are not measured zeroes. diff --git a/package-lock.json b/package-lock.json index 4e3bb477..1d4378d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.3-dev1.3", + "version": "2.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.3-dev1.3", + "version": "2.0.5", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 7d5fc566..c983950e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.3-dev1.3", + "version": "2.0.5", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/App.tsx b/src/App.tsx index 9335fb75..b648d4bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,8 +34,8 @@ import { shouldRetryQuery } from "./api/rate-limit"; import { WS_URL, ENABLED_TABS } from "./lib/constants"; import type { PacketDetail } from "./types/api"; -// Map is the only heavy tab (maplibre-gl is ~1MB), so lazy-load it — its chunk is fetched the -// first time someone opens the Map tab instead of bloating the initial bundle. +// Map (maplibre-gl, ~1MB) and My Atlas are lazy-loaded so their chunks are fetched on first visit +// instead of bloating the initial bundle. const MyAtlasPage = lazy(() => import("./features/atlas/MyAtlasPage").then(m => ({ default: m.MyAtlasPage }))); const MapView = lazy(() => import("./features/map/MapView").then((m) => ({ default: m.MapView }))); diff --git a/src/api/rate-limit.ts b/src/api/rate-limit.ts index 40e66292..63917499 100644 --- a/src/api/rate-limit.ts +++ b/src/api/rate-limit.ts @@ -26,6 +26,10 @@ export function shouldRetryQuery(failureCount: number, error: unknown): boolean return true; } +// TanStack Query retry delay: wait out a Retry-After when the server sent one, otherwise back off. +export const queryRetryDelay = (attempt: number, error: Error) => + (error as { retryAfterMs?: number }).retryAfterMs ?? Math.min(1000 * 2 ** attempt, 30_000); + // App-wide "the API is throttling us" flag: fed by the fetch wrapper, read by the header badge. let limitedUntil: number | null = null; diff --git a/src/components/BottomNav.tsx b/src/components/BottomNav.tsx index 4621efc7..36fb5a7e 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -5,7 +5,8 @@ import { ENABLED_TABS } from "../lib/constants"; import { touchClick } from "../lib/touch-click"; // Mobile-only tab bar (hidden at md+); overflow tabs live behind "More" in a bottom sheet. - +const PRIMARY_TABS = ["Packets", "Channels", "Map", "Nodes"] as const; +const OVERFLOW_TABS = ["Observers", "Routes", "Traces", "Analytics", "MyAtlas"] as const; // inline SVGs, 20px / 1.6 stroke to match the rest of the icons function Icon({ name }: { name: string }) { const common = { width: 20, height: 20, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const }; @@ -113,8 +114,9 @@ function MoreSheet({ tabs, activeTab, onPick, onClose }: { tabs: string[]; activ export function BottomNav({ activeTab, onTabChange }: { activeTab: string; onTabChange: (tab: string) => void }) { const { t } = useTranslation(); const [sheetOpen, setSheetOpen] = useState(false); - const primary = ENABLED_TABS.slice(0, 4); - const overflow = ENABLED_TABS.slice(4); + const enabled = new Set(ENABLED_TABS); + const primary: string[] = PRIMARY_TABS.filter((t) => enabled.has(t)); + const overflow: string[] = OVERFLOW_TABS.filter((t) => enabled.has(t)); const overflowActive = overflow.includes(activeTab); const pick = (tab: string) => { diff --git a/src/components/Sparkline.tsx b/src/components/Sparkline.tsx deleted file mode 100644 index cdd9e96b..00000000 --- a/src/components/Sparkline.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { useTranslation } from "react-i18next"; - -const SPARK_W = 120; -const SPARK_H = 24; - -export function Sparkline({ values, color, markPeak, times, gapMs }: { values: (number | null)[]; color: string; markPeak?: boolean; times?: number[]; gapMs?: number }) { - const { t } = useTranslation(); - const known = values.filter((v): v is number => v !== null && Number.isFinite(v)); - const max = known.length ? Math.max(...known) : 0, min = known.length ? Math.min(...known) : 0; - const range = max - min; - const timed = times?.length === values.length && times.every((v,i) => Number.isFinite(v) && (!i || v > times[i-1]!)) && times.at(-1)! > times[0]! ? times : null; - const x = (i: number) => timed ? 2 + (timed[i]! - timed[0]!) / (timed.at(-1)! - timed[0]!) * (SPARK_W - 4) : values.length > 1 ? 2 + i / (values.length - 1) * (SPARK_W - 4) : SPARK_W / 2; - const y = (v: number) => range ? SPARK_H - 3 - (v - min) / range * (SPARK_H - 6) : SPARK_H / 2; - const runs: string[][] = [], bridges: [number,number][] = []; - let previous = -1; - values.forEach((v,i) => { - if (v === null || !Number.isFinite(v)) return; - const gap = previous >= 0 && (i > previous + 1 || !!(timed && gapMs && timed[i]! - timed[previous]! > gapMs)); - if (previous < 0 || gap) runs.push([]); - if (gap) bridges.push([previous,i]); - runs.at(-1)!.push(`${x(i)},${y(v)}`); previous = i; - }); - const peak = values.indexOf(max); - return - {t("charts.gapLegend")} - {known.length === 0 && } - {bridges.map(([a,b]) => )} - {runs.map(run => run.length > 1 ? : )} - {markPeak && known.length > 0 && } - ; -} diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index bc0cd36f..54f5b3d2 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -1,11 +1,19 @@ -import { Sparkline } from "../../components/Sparkline"; -import { useId } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { getPacketDetail } from "../../api/client"; +import { isNotFound } from "../../api/client"; +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 { useTick } from "../../hooks/useTick"; -import { loadAtlasNode, REPORT_LIMIT, summarizeReports, type AtlasPin, type AtlasRange } from "./atlas"; +import { formatCount, formatHex } from "../../lib/formatters"; +import type { Observation } from "../../types/api"; +import { usePacketDetail } from "../packets/usePacketDetail"; +import { Card, StatCard } from "../stats/cards"; +import { useChartColors } from "../stats/chartTheme"; +import { average } from "../stats/signal"; +import { REPORT_LIMIT, type AtlasPin, type AtlasRange } from "./atlas"; +import { useAtlasNode } from "./useAtlas"; export interface AtlasActions { onViewNode: (id: string) => void; @@ -13,99 +21,211 @@ export interface AtlasActions { onAnalyzePacket: (hash: string, observationId?: number) => void; } -function SignalMeter({ label, value, values, min, max, unit, color }: { label: string; value: number | null; values: (number|null)[]; min: number; max: number; unit: string; color: string }) { - const { t, i18n } = useTranslation(); - const text = value === null ? t("atlas.unavailable") : `${value.toLocaleString(i18n.language, { maximumFractionDigits: 1 })} ${unit}`; - return
-
{label}{value===null ? "—" : text}
- -
; +interface AtlasCardProps extends AtlasActions { + pin: AtlasPin; + range: AtlasRange; + active: boolean; + heardOpen: boolean; + onHeardToggle: () => void; + onRemove: () => void; + onMove: (direction: -1 | 1) => void; + onResolved: (id: string) => void; + first: boolean; + last: boolean; } -export function AtlasCard({ pin, range, active, heardOpen, onHeardToggle, onRemove, onMove, first, last, onViewNode, onViewObserver, onAnalyzePacket }: AtlasActions & { - pin: AtlasPin; range: AtlasRange; active: boolean; heardOpen: boolean; onHeardToggle: () => void; - onRemove: () => void; onMove: (direction: -1 | 1) => void; first: boolean; last: boolean; -}) { - const { t, i18n } = useTranslation(); - const now = useTick(60_000); - const id = useId(); - const query = useQuery({ queryKey: ["atlas-node", pin.publicKey], queryFn: ({ signal }) => loadAtlasNode(pin, signal), enabled: active, staleTime: 60_000, retry: false, - refetchInterval: q => q.state.error ? false : 60_000, refetchIntervalInBackground: false }); - const node = query.data?.node; - const sample = summarizeReports(query.data?.page.items ?? [], range, now); +// One pinned node, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way. +export function AtlasCard({ + pin, + range, + active, + heardOpen, + onHeardToggle, + onRemove, + onMove, + onResolved, + first, + last, + onViewNode, + onViewObserver, + onAnalyzePacket, +}: AtlasCardProps) { + const { t } = useTranslation(); + const c = useChartColors(); + const { query, node, sample, capped } = useAtlasNode(pin, range, active); const latest = sample.reports[0]; - const heard = useQuery({ queryKey: ["packet-detail", latest?.packetHash], queryFn: ({ signal }) => getPacketDetail(latest!.packetHash, signal), enabled: active && heardOpen && !!latest, staleTime: 30_000, retry: false }); + const heard = usePacketDetail(heardOpen && latest ? latest.packetHash : null); const detail = heard.data; - const correctOrigin = !detail || !detail.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey; - const observers = correctOrigin ? [...new Map([...(detail?.observations ?? [])].sort((a, b) => a.heardAt - b.heardAt).map(row => [row.observerId, row])).values()].sort((a, b) => b.heardAt - a.heardAt) : []; - const name = node?.name || pin.name || pin.publicKey.slice(0, 12).toUpperCase(); - const number = (value: number) => value.toLocaleString(i18n.language, { maximumFractionDigits: 1 }); - const maximum = Math.max(1, ...sample.bins); - const time = (value: number) => new Date(value).toLocaleString(i18n.language, { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); - const button = "min-h-8 rounded-sm border border-border bg-bg-raised px-3 text-xs hover:border-primary disabled:opacity-40 disabled:cursor-default"; - return
-
-
-
-
-

- {t(`atlas.roles.${node?.nodeType ?? 0}`, { defaultValue: t("atlas.roles.0") })} -
-
-
-
- {query.isPending ?

{t("common.loading")}

: query.isError &&

{t(node ? "atlas.refreshFailed" : "atlas.nodeUnavailable")}

} - {node && <> -
-
{t("atlas.reports")}{number(sample.reports.length)}
- - {sample.bins.map((count, index) => {time(sample.since + index * 3_600_000)}: {count})} - -
{t(`atlas.ranges.${range}`)}{t("atlas.now")}
-

{t("atlas.sample", { limit: REPORT_LIMIT })}

-
-
- - -
- {!sample.reports.length &&

{t("atlas.noReports")}

} -
- - -
- {heardOpen &&
-

{t("atlas.latestAdvert")}

- {!latest ?

{t("atlas.noReports")}

: heard.isPending ?

{t("common.loading")}

: heard.isError || !correctOrigin ?

{t("atlas.heardUnavailable")}

: <> -

{t("atlas.observers", { count: observers.length })}

-
    {observers.map(row =>
  • {row.iata}
  • )}
- - } -
} -
- {t("atlas.statistics")} -
-

{t("atlas.definition")}

-

{t("atlas.signalDefinition")}

- {query.data?.page.hasMore &&

{t("atlas.capped")}

} -
-
{t("atlas.hops")}
{sample.hops === null ? "—" : number(sample.hops)}
-
{t("atlas.neighbors")}
{number(node.knownNeighborCount)}
-
{t("atlas.radio")}
{node.radio || "—"}
-
{t("atlas.scope")}
{node.defaultScope || "—"}
-
-

{node.publicKey}

-
{t("atlas.chartValues")}
{sample.bins.map((count, index) => )}
{t("atlas.hour")}{t("atlas.reports")}
{time(sample.since + index * 3_600_000)}{count}
-

{t("atlas.updated")}

-
-
- } -
- {t("atlas.manage")} -
-
+ // 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 observers = useMemo(() => { + if (!detail || !sameOrigin) return []; + const latestByObserver = new Map(); + for (const row of detail.observations) { + const previous = latestByObserver.get(row.observerId); + if (!previous || row.heardAt > previous.heardAt) latestByObserver.set(row.observerId, row); + } + 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. + useEffect(() => { + if (node && node.id !== pin.id) onResolved(node.id); + }, [node, pin.id, onResolved]); + + const name = node?.name || pin.name; + const hours = sample.counts.length; + const activeHours = sample.present.filter(Boolean).length; + + const title = ( + + + {node && {node.nodeTypeName}} + {node?.stale === false && {t("observerPage.statusRecent")}} + {node?.stale === true && {t("observerPage.statusStale")}} + + ); + + const actions = ( +
+ + + +
-
; + ); + + return ( + + {query.isPending &&

{t("common.loading")}

} + {query.isError && isNotFound(query.error) && ( +

{t("nodeDetail.notFound")}

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

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

+ )} + {node && ( +
+
+ + + + +
+
+ {node.lastSeen > 0 && ( + + {t("atlas.lastSeen")} + + )} + + {t("atlas.meanHops")} {average(sample.hops)} + + {t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })} + +
+ {heardOpen && ( +
+ {!latest ? ( +

{t("atlas.noReports")}

+ ) : heard.isPending ? ( +

{t("common.loading")}

+ ) : heard.isError || !sameOrigin ? ( +

{t("atlas.heardUnavailable")}

+ ) : ( + <> +

+ {t("atlas.observers", { count: observers.length })} +

+
    + {observers.map((row) => ( +
  • + + {row.iata} +
  • + ))} +
+ + + )} +
+ )} +
+ )} +
+ ); } diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index b38c9088..cb86eff2 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -1,72 +1,189 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { getNodesPage } from "../../api/client"; -import { SelectDropdown } from "../../components/SelectDropdown"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { Badge } from "../../components/Badge"; +import { EmptyState } from "../../components/EmptyState"; +import { InfoTip } from "../../components/InfoTip"; +import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; +import { Tooltip } from "../../components/Tooltip"; +import { formatHex } from "../../lib/formatters"; import { nodeSearchParams } from "../nodes/node-search"; +import type { NodeSummary } from "../nodes/types"; +import { Card } from "../stats/cards"; +import { Segmented } from "../stats/Segmented"; import { AtlasCard, type AtlasActions } from "./AtlasCard"; import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas"; import { useSavedAtlas } from "./useSavedAtlas"; +const RANGES: AtlasRange[] = ["24h", "3d"]; +const SEARCH_LIMIT = 8; +const SEARCH_MIN = 2; + +// Global by design: pins are node identities, so the header region picker does not apply here. export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) { const { t } = useTranslation(); const { saved, update, storageFailed } = useSavedAtlas(); - const [searchOpen, setSearchOpen] = useState(saved.nodes.length === 0); - const [text, setText] = useState(""); + const [search, setSearch] = useState(""); const [field, setField] = useState("name"); - const [query, setQuery] = useState({ text: "", field: "name" }); const [heardKey, setHeardKey] = useState(null); - useEffect(() => { const timer = setTimeout(() => setQuery({ text: text.trim(), field }), 300); return () => clearTimeout(timer); }, [text, field]); - const valid = query.text.length >= 2 && (query.field !== "pubkey" || /^[a-f\d]{2,64}$/i.test(query.text)); - const ready = query.text === text.trim() && query.field === field && valid; - const search = useQuery({ queryKey: ["atlas-search", query.field, query.text], queryFn: ({ signal }) => getNodesPage(undefined, { ...nodeSearchParams(query.field, query.text), limit: 8 }, signal), enabled: active && searchOpen && ready, staleTime: 30_000, retry: false }); + // Switching the field changes what the box means, so stale text must not carry over. + const changeField = (value: string) => { + setField(value); + setSearch(""); + }; + + // Key on the derived params, as the Nodes table does, so a name is never sent as a hex prefix. + const params = nodeSearchParams(field, search); + const typed = search.trim(); + const hint = typed.length === 0 ? null + : typed.length < SEARCH_MIN ? "atlas.searchTooShort" + : 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, + staleTime: 30_000, + }); + const full = saved.nodes.length >= ATLAS_LIMIT; - const empty = saved.nodes.length === 0; - return
-
-
-
- -

{t("tabs.MyAtlas")}

{t("atlas.subtitle")}

-
-
- {(["24h", "3d"] as AtlasRange[]).map(range => )} + const fields: SearchFieldOption[] = [ + { value: "name", label: t("nodeFilters.fieldName") }, + { value: "pubkey", label: t("nodeFilters.fieldPubkey") }, + ]; + 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 add = (node: NodeSummary) => + update((value) => { + const pin = { id: node.id, publicKey: node.publicKey, name: node.name || "" }; + return parseAtlas(JSON.stringify({ ...value, nodes: [...value.nodes, pin] })); + }); + const remove = (publicKey: string) => + update((value) => ({ ...value, nodes: value.nodes.filter((node) => node.publicKey !== publicKey) })); + const relink = (publicKey: string, id: string) => + update((value) => ({ ...value, nodes: value.nodes.map((node) => (node.publicKey === publicKey ? { ...node, id } : node)) })); + const move = (publicKey: string, direction: -1 | 1) => + update((value) => { + const nodes = [...value.nodes]; + const from = nodes.findIndex((node) => node.publicKey === publicKey); + const to = from + direction; + if (from < 0 || to < 0 || to >= nodes.length) return value; + [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; + return { ...value, nodes }; + }); + + return ( +
+
+
+ {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} + + {t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")} +
-
-
setSearchOpen(event.currentTarget.open)} className="rounded-sm border border-border bg-bg-surface px-4 py-2"> - {t("atlas.addNodes")} {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} -
-
- - setText(event.target.value)} placeholder={t(field === "pubkey" ? "atlas.keyPlaceholder" : "atlas.namePlaceholder")} className="min-h-11 min-w-0 flex-1 rounded-lg border border-border bg-bg-base px-3 text-sm text-text-bright placeholder:text-text-muted" /> -
+ update((value) => ({ ...value, range: range as AtlasRange }))} + ariaLabel={t("atlas.range")} + /> +
+
+
+ } + > + + {hint &&

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

} + {ready && ( +
+ {results.isPending ? ( +

{t("common.loading")}

+ ) : results.isError ? ( +

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

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

{t("common.noMatches")}

+ ) : ( +
    + {results.data.items.map((node) => { + const pinned = saved.nodes.some((pin) => pin.publicKey === node.publicKey.toLowerCase()); + const label = node.name ?? formatHex(node.id); + return ( +
  • + + {label} + {node.nodeTypeName} + + {formatHex(node.publicKey)} + + + +
  • + ); + })} +
+ )} + {results.data?.hasMore && ( +

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

+ )} + {full &&

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

} +
+ )} +
+ {saved.nodes.length === 0 ? ( + + ) : ( +
+ {saved.nodes.map((pin, index) => ( +
+ setHeardKey((key) => (key === pin.publicKey ? null : pin.publicKey))} + onRemove={() => remove(pin.publicKey)} + onMove={(direction) => move(pin.publicKey, direction)} + onResolved={(id) => relink(pin.publicKey, id)} + {...actions} + /> +
+ ))} +
+ )}
-

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

- {ready &&
- {search.isPending ?

{t("common.loading")}

: search.isError ?

{t("common.loadFailed")}

: <> - {!search.data?.items.length &&

{t("atlas.noMatches")}

} -
    {search.data?.items.map(node => { - const selected = saved.nodes.some(pin => pin.publicKey === node.publicKey.toLowerCase()); - const name = node.name || node.publicKey.slice(0, 12).toUpperCase(); - return
  • -

    {name}

    {node.publicKey.slice(0, 16).toUpperCase()} · {t(`atlas.roles.${node.nodeType}`)}

    - -
  • ; - })}
- {search.data?.hasMore &&

{t("atlas.refine")}

} - } -
} -
-

{t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")}

-

{t("charts.gapLegend")}

- {empty ?

{t("atlas.emptyTitle")}

{t("atlas.emptyDescription")}

:
- {saved.nodes.map((pin, index) => setHeardKey(value => value === pin.publicKey ? null : pin.publicKey)} onRemove={() => update(value => ({ ...value, nodes: value.nodes.filter(node => node.publicKey !== pin.publicKey) }))} onMove={direction => update(value => { - const nodes = [...value.nodes]; const from = nodes.findIndex(node => node.publicKey === pin.publicKey); const to = from + direction; - if (from < 0 || to < 0 || to >= nodes.length) return value; - [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; - return { ...value, nodes }; - })} {...actions} />)} -
} -
-
; +
+ + ); } diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index d5074f8d..b6bc357b 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -1,4 +1,4 @@ -import { getNode, getNodeObservations, getNodesPage } from "../../api/client"; +import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from "../../api/client"; import type { Node, NodeObservation } from "../nodes/types"; export const ATLAS_KEY = "beacon-my-atlas-v1"; @@ -30,12 +30,15 @@ export function parseAtlas(raw: string | null): SavedAtlas { // 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) { let node: Node | undefined; - try { node = await getNode(pin.id, signal); } - catch (error) { if (!(error instanceof Error && "status" in error && error.status === 404)) throw error; } + try { + node = await getNode(pin.id, signal); + } catch (error) { + if (!isNotFound(error)) throw error; + } if (node?.publicKey.toLowerCase() !== pin.publicKey) { const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); const match = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey); - if (!match) throw new Error("Atlas identity unavailable"); + if (!match) throw new ApiError(404, "not_found", "Atlas identity unavailable"); node = await getNode(match.id, signal); } if (node.publicKey.toLowerCase() !== pin.publicKey) throw new Error("Atlas identity changed"); @@ -43,27 +46,79 @@ export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) { return { node, page }; } -export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now: number) { - const hours = range === "3d" ? 72 : 24; - const since = now - hours * 3_600_000; +const HOUR = 3_600_000; +const RANGE_HOURS: Record = { "24h": 24, "3d": 72 }; + +export interface AtlasSample { + reports: NodeObservation[]; + since: number; + counts: number[]; + present: boolean[]; + snrHourly: (number | null)[]; + rssiHourly: (number | null)[]; + snr: number | null; + rssi: number | null; + hops: number | null; + truncated: boolean; +} + +// Zero RSSI with zero SNR is the server's "no measurement" sentinel, not a reading. +const hasSignal = (row: NodeObservation) => !(row.rssi === 0 && row.snr === 0); +const finite = (value: number | undefined): value is number => typeof value === "number" && Number.isFinite(value); +const mean = (sum: number, n: number) => (n ? sum / n : null); + +export function summarizeReports(rows: NodeObservation[], range: AtlasRange, now: number, hasMore = false): AtlasSample { + const windowSince = now - RANGE_HOURS[range] * HOUR; const seen = new Set(); - const reports = rows.filter(row => { - if (seen.has(row.id) || row.heardAt <= since || row.heardAt > now) return false; - seen.add(row.id); - return Number.isFinite(row.heardAt); - }).sort((a, b) => b.heardAt - a.heardAt || b.id - a.id); - const bins = Array(hours).fill(0); + const reports = rows + .filter((row) => { + if (seen.has(row.id) || !Number.isFinite(row.heardAt) || row.heardAt < windowSince) return false; + seen.add(row.id); + return true; + }) + .sort((a, b) => b.heardAt - a.heardAt || b.id - a.id); + // hasMore alone says nothing about the window; the sample is only short when the oldest loaded row is still inside it. + const oldestLoaded = rows.reduce( + (min, row) => (Number.isFinite(row.heardAt) && (min === undefined || row.heardAt < min) ? row.heardAt : min), + undefined, + ); + const truncated = hasMore && oldestLoaded !== undefined && oldestLoaded > windowSince; + const since = truncated ? Math.floor(oldestLoaded / HOUR) * HOUR : windowSince; + const hours = Math.max(1, Math.ceil((now - since) / HOUR)); + const counts = Array(hours).fill(0); + const sums = { snr: Array(hours).fill(0), rssi: Array(hours).fill(0) }; + const samples = { snr: Array(hours).fill(0), rssi: Array(hours).fill(0) }; + const total = { snr: 0, rssi: 0, hops: 0 }; + const n = { snr: 0, rssi: 0, hops: 0 }; for (const row of reports) { - const index = Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)); - bins[index] = (bins[index] ?? 0) + 1; + // A report newer than `now` (the tick snapshot) lands in the latest hour. + const bin = Math.min(hours - 1, Math.max(0, Math.floor((row.heardAt - since) / HOUR))); + counts[bin]!++; + if (finite(row.hopCount)) { + total.hops += row.hopCount; + n.hops++; + } + if (!hasSignal(row)) continue; + for (const metric of ["snr", "rssi"] as const) { + const value = row[metric]; + if (!finite(value)) continue; + sums[metric][bin]! += value; + samples[metric][bin]!++; + total[metric] += value; + n[metric]++; + } } - const signal = reports.filter(row => !(row.rssi === 0 && row.snr === 0)); - const mean = (values: (number | undefined)[]) => { - const valid = values.filter((value): value is number => typeof value === "number" && Number.isFinite(value)); - return valid.length ? valid.reduce((a, b) => a + b, 0) / valid.length : null; + const hourly = (metric: "snr" | "rssi") => counts.map((_, i) => mean(sums[metric][i]!, samples[metric][i]!)); + return { + reports, + since, + counts, + present: counts.map((count) => count > 0), + snrHourly: hourly("snr"), + rssiHourly: hourly("rssi"), + snr: mean(total.snr, n.snr), + rssi: mean(total.rssi, n.rssi), + hops: mean(total.hops, n.hops), + truncated, }; - const hourly = (field: "snr" | "rssi") => bins.map((_, index) => mean(signal - .filter(row => Math.min(hours - 1, Math.floor((row.heardAt - since) / 3_600_000)) === index) - .map(row => row[field]))); - return { reports, bins, since, snrBins: hourly("snr"), rssiBins: hourly("rssi"), snr: mean(signal.map(row => row.snr)), rssi: mean(signal.map(row => row.rssi)), hops: mean(reports.map(row => row.hopCount)) }; } diff --git a/src/features/atlas/useAtlas.ts b/src/features/atlas/useAtlas.ts new file mode 100644 index 00000000..5dfc31cd --- /dev/null +++ b/src/features/atlas/useAtlas.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; +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"; + +export const ATLAS_REFRESH_MS = 60_000; + +export function useAtlasNode(pin: AtlasPin, 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, + staleTime: ATLAS_REFRESH_MS, + refetchOnWindowFocus: false, + refetchInterval: ATLAS_REFRESH_MS, + refetchIntervalInBackground: false, + retryDelay: queryRetryDelay, + }); + const items = query.data?.page.items; + const hasMore = query.data?.page.hasMore ?? false; + const sample = useMemo(() => summarizeReports(items ?? [], range, now, hasMore), [items, range, now, hasMore]); + return { query, node: query.data?.node, sample, capped: sample.truncated }; +} diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts index f71dadbd..5e74d362 100644 --- a/src/features/atlas/useSavedAtlas.ts +++ b/src/features/atlas/useSavedAtlas.ts @@ -3,8 +3,11 @@ import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; export function useSavedAtlas() { const [initial] = useState(() => { - try { return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; } - catch { return { value: parseAtlas(null), failed: true }; } + try { + return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; + } catch { + return { value: parseAtlas(null), failed: true }; + } }); const [saved, setSaved] = useState(initial.value); const [storageFailed, setStorageFailed] = useState(initial.failed); @@ -22,8 +25,12 @@ export function useSavedAtlas() { const next = change(current.current); current.current = next; setSaved(next); - try { localStorage.setItem(ATLAS_KEY, JSON.stringify(next)); setStorageFailed(false); } - catch { setStorageFailed(true); } + try { + localStorage.setItem(ATLAS_KEY, JSON.stringify(next)); + setStorageFailed(false); + } catch { + setStorageFailed(true); + } }; return { saved, update, storageFailed }; } diff --git a/src/features/observers/useObserverDirectory.ts b/src/features/observers/useObserverDirectory.ts index 5ead4a19..f856900c 100644 --- a/src/features/observers/useObserverDirectory.ts +++ b/src/features/observers/useObserverDirectory.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from "react"; import { useInfiniteQuery, useQuery, useQueryClient, type InfiniteData } from "@tanstack/react-query"; import { getObserverDirectoryPage, supportsObserverDirectory } from "../../api/client"; +import { queryRetryDelay } from "../../api/rate-limit"; import { API_BASE } from "../../lib/constants"; import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers"; import { useStatsRegion } from "../stats/useStats"; @@ -18,9 +19,6 @@ export interface ObserverDirectoryOptions { } type Continuation = { cursor: number; since: number; until: number } | undefined; -const retryDelay = (attempt: number, error: Error) => - (error as { retryAfterMs?: number }).retryAfterMs ?? Math.min(1000 * 2 ** attempt, 30_000); - export function useObserverDirectory(wsManager: WsManager, options: ObserverDirectoryOptions) { const { where, regionKey, isResolved } = useStatsRegion(); const { sort, search, status, type, broker, scope } = options; @@ -30,7 +28,7 @@ export function useObserverDirectory(wsManager: WsManager, options: ObserverDire queryFn: ({ signal }) => supportsObserverDirectory(signal), enabled: isResolved !== false, staleTime: 60_000, - retryDelay, + retryDelay: queryRetryDelay, }); const queryKey = useMemo(() => ["observer-directory", API_BASE, regionKey, sort, search, status, type, broker, scope], [regionKey, sort, search, status, type, broker, scope]); @@ -52,7 +50,7 @@ export function useObserverDirectory(wsManager: WsManager, options: ObserverDire staleTime: Infinity, refetchInterval: 60_000, gcTime: 0, - retryDelay, + retryDelay: queryRetryDelay, }); const first = list.data?.pages[0]; const sortChanged = list.data?.pages.some(page => page.effectiveSort !== first?.effectiveSort) ?? false; diff --git a/src/features/stats/SignalTab.tsx b/src/features/stats/SignalTab.tsx index 326113e7..96774bb8 100644 --- a/src/features/stats/SignalTab.tsx +++ b/src/features/stats/SignalTab.tsx @@ -5,12 +5,10 @@ import { SectionInfo } from "./SectionInfo"; import { formatCount, formatUtc } from "../../lib/formatters"; import { Card, ChartCard, StatCard } from "./cards"; import { useChartColors } from "./chartTheme"; -import { signalBinLabel, signalCoverageOption, signalHistogramOption, signalHours, signalTrendOption } from "./signal"; +import { average, signalBinLabel, signalCoverageOption, signalHistogramOption, signalHours, signalTrendOption } from "./signal"; import { useSignalStats } from "./useSignalStats"; import type { StatsRange } from "./types"; -const average = (value: number | null | undefined, unit = "") => value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`; - export function SignalTab({ range }: { range: StatsRange }) { const { t } = useTranslation(); const query = useSignalStats(range); diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index d10e2379..5655e362 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -9,16 +9,18 @@ export function Card({ right, children, className, + wrap, }: { title: ReactNode; right?: ReactNode; children: ReactNode; className?: string; + wrap?: boolean; // let `right` drop under the title when the card is too narrow for both }) { return (
-
-
{title}
+
+
{title}
{right}
{children} diff --git a/src/features/stats/signal.ts b/src/features/stats/signal.ts index dddad107..ca8e9398 100644 --- a/src/features/stats/signal.ts +++ b/src/features/stats/signal.ts @@ -4,6 +4,9 @@ import type { SignalBin, SignalMetric, SignalStats } from "./types"; import { blend, tooltipStyle, withAlpha, type ChartColors } from "./chartTheme"; const HOUR = 3_600_000; + +// One decimal with its unit, "—" when there is no sample. Shared by the Signal tab and Atlas cards. +export const average = (value: number | null | undefined, unit = "") => (value == null ? "—" : `${value.toFixed(1)}${unit ? ` ${unit}` : ""}`); export function signalHours(data: SignalStats | undefined) { if (!data || data.until <= data.since) return []; const first = Math.floor(data.since / HOUR) * HOUR; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4e4f6acc..5012799f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -859,8 +859,7 @@ }, "charts": { "uniquePackets": "Unique packets", - "busy": "Busy", - "gapLegend": "Solid lines and dots show readings. Dashed lines estimate between missing samples." + "busy": "Busy" }, "packetList": { "pathSearchInvalid": "Enter whole hop hashes of the same length, separated by spaces, commas or → (e.g. 7f a4).", @@ -923,76 +922,42 @@ "perDay": "{{value}}/d" }, "atlas": { - "subtitle": "Your nodes, at a glance.", - "range": "Activity window", - "ranges": { - "24h": "24 hours", - "3d": "3 days" - }, "addNodes": "Add nodes", - "searchBy": "Search", - "name": "Name", - "publicKey": "Public key", - "search": "Find a node to save", - "namePlaceholder": "Search by node name…", - "keyPlaceholder": "Public key or hex prefix…", - "savedCount": "{{count}} / {{limit}} saved", - "searchHelp": "Search all regions. Enter at least two characters; a public key uses hexadecimal characters.", - "limitReached": "All {{limit}} card spaces are in use. Remove a card to add another.", - "noMatches": "No matching nodes. Try a different name or key.", - "add": "+ Add", - "added": "Saved", + "search": "Find a node", + "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", + "savedCount_other": "{{count}} / {{limit}} saved", + "localOnly": "Saved in this browser", + "storageFailed": "Browser storage is unavailable; changes last only for this visit.", + "limitReached": "All {{limit}} slots are in use. Remove a card to add another.", + "refine": "Showing the first {{limit}} matches; refine the search for more.", + "add": "Add", + "saved": "Saved", "addName": "Add {{name}}", - "addedName": "{{name}} is saved", - "refine": "Showing the first eight matches. Refine your search to find more.", - "localOnly": "Saved in this browser · All regions", - "storageFailed": "Browser saving is unavailable. Your changes work for this visit but may not be kept.", - "emptyTitle": "Build your own view of the mesh", - "emptyDescription": "Find a repeater, companion or other node above and add a card. Your selection and card order will be here when you return.", - "roles": { - "0": "Unknown", - "1": "Companion", - "2": "Repeater", - "3": "Room", - "4": "Sensor" + "savedName": "{{name}} is saved", + "emptyTitle": "No nodes saved", + "emptyDescription": "Search above to add up to {{limit}} nodes", + "range": "Activity window", + "ranges": { + "24h": "24h", + "3d": "3d" }, - "unavailable": "Unavailable", - "recent": "Recently seen", - "stale": "Not seen recently", - "freshnessUnknown": "Freshness unknown", - "refreshFailed": "Refresh failed; showing the last loaded sample.", - "nodeUnavailable": "This saved node could not be loaded. It may have expired or the server may be unavailable.", - "retry": "Retry", - "activity": "Received activity", - "reports": "Received reports", - "activityLabel": "{{count}} received reports in the loaded {{range}} sample. Hourly values are available under Statistics.", - "now": "Now", - "sample": "Loaded sample · up to {{limit}} recent reports", - "snr": "Mean SNR", - "rssi": "Mean RSSI", - "noReports": "No retained reports in this window. This does not establish whether the node is offline.", + "sampleCapped": "latest {{limit}}", + "sampleHelp": "Latest {{limit}} stored observations of packets from this node, bucketed per hour. Signal is measured by observers on the last hop.", + "cappedHelp": "More observations exist than this card loads; the bars start at the oldest loaded one.", + "lastSeen": "Last seen", + "meanHops": "Mean hops", "heardBy": "Heard by", - "openNode": "Open node", - "latestAdvert": "Observers of the latest loaded packet", - "heardUnavailable": "The packet details are unavailable or have expired. Try another retained packet from the node profile.", - "observers_one": "{{count}} reporting observer", - "observers_other": "{{count}} reporting observers", - "openAdvert": "Open packet and observed paths", - "statistics": "Statistics & chart values", - "definition": "Each report is one stored packet reception originating from this full node key, using server deduplication. These are sampled reports, not a total of all node traffic. Blank bars mean no loaded reports; older raw history may have expired.", - "signalDefinition": "Signal meters average available last-hop measurements at the receiving observers. They do not measure the originating node’s radio health. The hourly bars use a rolling window, ending now.", - "capped": "More reports exist than this card loads. Counts and averages cover only the loaded sample.", - "hops": "Mean hops", - "neighbors": "Known neighbours", - "radio": "Radio settings", - "scope": "Default scope", - "chartValues": "Hourly values", - "hour": "Hour starting", - "updated": "Sample loaded", - "manage": "Manage card", - "earlier": "Move earlier", - "later": "Move later", - "remove": "Remove card" + "observers_one": "{{count}} observer heard the latest packet", + "observers_other": "{{count}} observers heard the latest packet", + "noReports": "No observations in this window.", + "heardUnavailable": "Packet details are unavailable or expired.", + "openPacket": "Open packet", + "moveEarlier": "Move earlier", + "moveLater": "Move later", + "remove": "Remove" } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index a31f21e0..2f52cdc1 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -893,8 +893,7 @@ }, "charts": { "uniquePackets": "Paquets uniques", - "busy": "Occupation", - "gapLegend": "Traits pleins et points : mesures. Tirets : estimation entre des données manquantes." + "busy": "Occupation" }, "packetList": { "pathSearchInvalid": "Saisissez des hashs de saut complets de même longueur, séparés par des espaces, des virgules ou → (p. ex. 7f a4).", @@ -959,76 +958,44 @@ "perDay": "{{value}}/j" }, "atlas": { - "subtitle": "Vos nœuds en un coup d’œil.", - "range": "Période d’activité", - "ranges": { - "24h": "24 heures", - "3d": "3 jours" - }, "addNodes": "Ajouter des nœuds", - "searchBy": "Recherche", - "name": "Nom", - "publicKey": "Clé publique", - "search": "Trouver un nœud à enregistrer", - "namePlaceholder": "Rechercher par nom de nœud…", - "keyPlaceholder": "Clé publique ou préfixe hexadécimal…", - "savedCount": "{{count}} / {{limit}} enregistrés", - "searchHelp": "Recherchez dans toutes les régions. Saisissez au moins deux caractères ; une clé publique utilise des caractères hexadécimaux.", + "search": "Trouver un nœud", + "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é", + "savedCount_many": "{{count}} / {{limit}} enregistrés", + "savedCount_other": "{{count}} / {{limit}} enregistrés", + "localOnly": "Enregistré dans ce navigateur", + "storageFailed": "Le stockage du navigateur est indisponible ; les modifications ne durent que cette visite.", "limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.", - "noMatches": "Aucun nœud correspondant. Essayez un autre nom ou une autre clé.", - "add": "+ Ajouter", - "added": "Enregistré", + "refine": "{{limit}} premiers résultats affichés ; précisez la recherche pour en voir d’autres.", + "add": "Ajouter", + "saved": "Enregistré", "addName": "Ajouter {{name}}", - "addedName": "{{name}} est enregistré", - "refine": "Les huit premiers résultats sont affichés. Précisez votre recherche pour en trouver d’autres.", - "localOnly": "Enregistré dans ce navigateur · Toutes les régions", - "storageFailed": "L’enregistrement dans le navigateur est indisponible. Vos modifications fonctionnent pour cette visite, mais risquent de ne pas être conservées.", - "emptyTitle": "Créez votre propre vue du réseau", - "emptyDescription": "Cherchez un répéteur, un compagnon ou un autre nœud ci-dessus et ajoutez une fiche. Votre sélection et l’ordre des fiches seront conservés pour votre prochaine visite.", - "roles": { - "0": "Inconnu", - "1": "Compagnon", - "2": "Répéteur", - "3": "Salon", - "4": "Capteur" + "savedName": "{{name}} est enregistré", + "emptyTitle": "Aucun nœud enregistré", + "emptyDescription": "Cherchez ci-dessus pour ajouter jusqu’à {{limit}} nœuds", + "range": "Période d’activité", + "ranges": { + "24h": "24 h", + "3d": "3 j" }, - "unavailable": "Indisponible", - "recent": "Vu récemment", - "stale": "Pas vu récemment", - "freshnessUnknown": "Actualité inconnue", - "refreshFailed": "Échec de l’actualisation ; affichage du dernier échantillon chargé.", - "nodeUnavailable": "Ce nœud enregistré n’a pas pu être chargé. Il a peut-être expiré ou le serveur est indisponible.", - "retry": "Réessayer", - "activity": "Activité reçue", - "reports": "Réceptions", - "activityLabel": "{{count}} réceptions dans l’échantillon chargé de {{range}}. Les valeurs horaires sont disponibles sous Statistiques.", - "now": "Maintenant", - "sample": "Échantillon chargé · jusqu’à {{limit}} réceptions récentes", - "snr": "SNR moyen", - "rssi": "RSSI moyen", - "noReports": "Aucune réception conservée pour cette période. Cela ne permet pas de conclure que le nœud est hors ligne.", + "sampleCapped": "{{limit}} dernières", + "sampleHelp": "{{limit}} dernières observations conservées de paquets provenant de ce nœud, par heure. Le signal est mesuré par les observateurs sur le dernier saut.", + "cappedHelp": "Il existe plus d’observations que cette fiche n’en charge ; les barres commencent à la plus ancienne chargée.", + "lastSeen": "Vu pour la dernière fois", + "meanHops": "Sauts moyens", "heardBy": "Entendu par", - "openNode": "Ouvrir le nœud", - "latestAdvert": "Observateurs du dernier paquet chargé", - "heardUnavailable": "Les détails du paquet sont indisponibles ou ont expiré. Essayez un autre paquet conservé depuis le profil du nœud.", - "observers_one": "{{count}} observateur ayant signalé le paquet", - "observers_other": "{{count}} observateurs ayant signalé le paquet", - "openAdvert": "Ouvrir le paquet et les chemins observés", - "statistics": "Statistiques et valeurs du graphique", - "definition": "Chaque réception correspond à un paquet provenant de cette clé de nœud complète, après déduplication par le serveur. Il s’agit d’un échantillon, pas du trafic total du nœud. Les barres vides indiquent l’absence de réceptions chargées ; l’historique brut plus ancien peut avoir expiré.", - "signalDefinition": "Les indicateurs font la moyenne des mesures disponibles du dernier saut aux observateurs récepteurs. Ils ne mesurent pas l’état de la radio du nœud d’origine. Les barres horaires couvrent une période glissante se terminant maintenant.", - "capped": "D’autres réceptions existent au-delà de celles chargées. Les nombres et moyennes portent uniquement sur l’échantillon chargé.", - "hops": "Sauts moyens", - "neighbors": "Voisins connus", - "radio": "Réglages radio", - "scope": "Portée par défaut", - "chartValues": "Valeurs horaires", - "hour": "Début de l’heure", - "updated": "Échantillon chargé", - "manage": "Gérer la fiche", - "earlier": "Déplacer avant", - "later": "Déplacer après", - "remove": "Retirer la fiche" + "observers_one": "{{count}} observateur a entendu le dernier paquet", + "observers_many": "{{count}} observateurs ont entendu le dernier paquet", + "observers_other": "{{count}} observateurs ont entendu le dernier paquet", + "noReports": "Aucune observation sur cette période.", + "heardUnavailable": "Détails du paquet indisponibles ou expirés.", + "openPacket": "Ouvrir le paquet", + "moveEarlier": "Déplacer avant", + "moveLater": "Déplacer après", + "remove": "Retirer" } } } diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 1ea5bedc..ff13ca8a 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -23,7 +23,7 @@ export const RATE_LIMIT_DEFAULT_MS = 10_000; export const RATE_LIMIT_MAX_MS = 300_000; // app tab names, in display order; the ?tab URL param is validated against this list -export const TABS = ["MyAtlas", "Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics"] as const; +export const TABS = ["Packets", "Channels", "Map", "Nodes", "Observers", "Routes", "Traces", "Analytics", "MyAtlas"] as const; // Per-deployment config: read via runtimeEnv, so the Docker image's /config.js (written at container // start, see .build/) overrides the VITE_* build-time env. diff --git a/tests/api/rate-limit.test.ts b/tests/api/rate-limit.test.ts index 52160fec..ccfd7369 100644 --- a/tests/api/rate-limit.test.ts +++ b/tests/api/rate-limit.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { parseRetryAfter, + queryRetryDelay, shouldRetryQuery, noteRateLimited, noteRequestOk, @@ -56,6 +57,14 @@ describe("shouldRetryQuery", () => { }); }); +describe("queryRetryDelay", () => { + it("honours Retry-After and otherwise backs off exponentially to a ceiling", () => { + expect(queryRetryDelay(0, Object.assign(new Error("429"), { retryAfterMs: 7_000 }))).toBe(7_000); + expect(queryRetryDelay(2, new Error("500"))).toBe(4_000); + expect(queryRetryDelay(10, new Error("500"))).toBe(30_000); + }); +}); + describe("rate-limit store", () => { it("starts un-limited", () => { expect(getRateLimitedUntil()).toBeNull(); diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index 6fb104bc..a830cffc 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -4,16 +4,15 @@ import { BottomNav } from "../../src/components/BottomNav"; import i18n from "../../src/i18n"; describe("BottomNav", () => { - it("puts My Atlas first and preserves its canonical identifier in French", async () => { + it("keeps Nodes primary and puts My Atlas in More, with its identifier intact in French", async () => { const onTabChange = vi.fn(); render(); - expect(screen.getAllByRole("tab")[0]).toHaveTextContent("My Atlas"); + expect(screen.getAllByRole("tab").map((tab) => tab.textContent)).toEqual(["Packets", "Channels", "Map", "Nodes"]); + expect(screen.getByText("More").closest("button")!.className).toContain("text-primary"); await act(() => i18n.changeLanguage("fr")); - fireEvent.click(screen.getByRole("tab", { name: "Mon Atlas" })); - expect(onTabChange).toHaveBeenCalledWith("MyAtlas"); fireEvent.click(screen.getByText("Plus")); - fireEvent.click(screen.getByRole("menuitem", { name: "Nœuds" })); - expect(onTabChange).toHaveBeenCalledWith("Nodes"); + fireEvent.click(screen.getByRole("menuitem", { name: "Mon Atlas" })); + expect(onTabChange).toHaveBeenCalledWith("MyAtlas"); }); it("translates an open More sheet without changing the selected tab identifier", async () => { const onTabChange = vi.fn(); diff --git a/tests/components/Sparkline.test.tsx b/tests/components/Sparkline.test.tsx deleted file mode 100644 index fbf13de2..00000000 --- a/tests/components/Sparkline.test.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { render } from "@testing-library/react"; -import { Sparkline } from "../../src/components/Sparkline"; - -describe("sparkline evidence", () => { - it("draws isolated measured zeros and dashed bridges, without changing the input", () => { - const values = [null, 0, null, 4, 5, null]; - const { container } = render(); - expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); - expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); - expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); - expect(values).toEqual([null, 0, null, 4, 5, null]); - }); - it("marks a long time gap and a single reading, without extrapolating measured points", () => { - const { container, rerender } = render(); - expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); - rerender(); - expect(container.querySelectorAll("[data-sample]")).toHaveLength(1); - expect(container.querySelectorAll("[data-gap]")).toHaveLength(0); - }); -}); diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx new file mode 100644 index 00000000..b78782f9 --- /dev/null +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } 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 { PacketDetail } from "../../../src/types/api"; + +vi.mock("../../../src/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + getNode: vi.fn(), + getNodeObservations: vi.fn(), + getNodesPage: vi.fn(), + getPacketDetail: vi.fn(), +})); + +const pin = { 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: [ + { observerId: "observer-a", observerName: "Home listener", id: 1, heardAt: 1, iata: "YKF" }, + { 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 handlers = { + onViewNode: vi.fn(), + onViewObserver: vi.fn(), + onAnalyzePacket: vi.fn(), + onHeardToggle: vi.fn(), + onRemove: vi.fn(), + onMove: vi.fn(), + onResolved: vi.fn(), +}; +let client: QueryClient; + +function mount(overrides: Partial[0]> = {}) { + return render( + + + , + ); +} + +beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.clearAllMocks(); + vi.mocked(api.getNode).mockResolvedValue(node); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 7 }); + vi.mocked(api.getPacketDetail).mockResolvedValue(detail); +}); +afterEach(() => client.clear()); + +describe("AtlasCard", () => { + it("renders the Signal tab tiles for one node with its type and freshness badges", async () => { + mount(); + expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(screen.getByText("REPEATER")).toBeInTheDocument(); + expect(screen.getByText("Recent status")).toBeInTheDocument(); + expect(screen.getByText("Observations")).toBeInTheDocument(); + expect(screen.getByText("Mean SNR")).toBeInTheDocument(); + expect(screen.getByText("0.0 dB")).toBeInTheDocument(); + expect(screen.getByText("-106.0 dBm")).toBeInTheDocument(); + expect(screen.getByText("Active hours")).toBeInTheDocument(); + expect(screen.getByText("latest 200")).toBeInTheDocument(); + expect(screen.queryByText(/battery|telemetry/i)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Renamed repeater" })); + expect(handlers.onViewNode).toHaveBeenCalledWith(pin.id); + }); + + it("shows the stale badge and the server's type name even when the type is unknown", async () => { + vi.mocked(api.getNode).mockResolvedValue({ ...node, stale: true, nodeType: 9, nodeTypeName: "WEIRD" }); + mount(); + expect(await screen.findByText("Status is stale")).toBeInTheDocument(); + expect(screen.getByText("WEIRD")).toBeInTheDocument(); + expect(screen.queryByText(/atlas\.roles/)).not.toBeInTheDocument(); + }); + + it("reports a server ID that differs from the pin so the page can update it", async () => { + const recovered = "22222222-2222-4222-8222-222222222222"; + vi.mocked(api.getNode).mockResolvedValue({ ...node, id: recovered }); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + expect(handlers.onResolved).toHaveBeenCalledWith(recovered); + }); + + it("leaves the pin alone when the server ID still matches", async () => { + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + expect(handlers.onResolved).not.toHaveBeenCalled(); + }); + + it("lets the action buttons drop under the title instead of squeezing the name", async () => { + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + const actions = screen.getByRole("button", { name: "Remove" }).parentElement!; + expect(actions).toHaveClass("ml-auto"); + expect(actions.parentElement).toHaveClass("flex-wrap"); + }); + + it("labels an unnamed node by its ID prefix, dimmed, as the Nodes page does", async () => { + vi.mocked(api.getNode).mockResolvedValue({ ...node, name: "" }); + mount({ pin: { ...pin, name: "" } }); + const title = await screen.findByRole("button", { name: formatHex(pin.id) }); + expect(title).toHaveClass("italic"); + }); + + it("says the node is gone instead of offering a retry when the server has no such key", async () => { + vi.mocked(api.getNode).mockRejectedValue(new ApiError(404, "not_found", "missing")); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); + mount(); + expect(await screen.findByText("Node not found")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Retry" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Remove" })).toBeEnabled(); + }); + + it("renders dashes for an empty sample without NaN", async () => { + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(2); + expect(screen.getByText("0/24")).toBeInTheDocument(); + expect(screen.queryByText(/NaN/)).not.toBeInTheDocument(); + expect(screen.getByText("24h")).toBeInTheDocument(); + }); + + it("loads heard-by only when open, dedupes observers and links to the packet", async () => { + const view = mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + expect(api.getPacketDetail).not.toHaveBeenCalled(); + view.rerender( + + + , + ); + const summary = await screen.findByText(/2 observers heard the latest packet/); + expect(summary).toHaveTextContent(/ago$/); + expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); + expect(screen.getAllByText("YKF")[0]).toHaveClass("bg-primary/6"); + fireEvent.click(screen.getByRole("button", { name: "Home listener" })); + expect(handlers.onViewObserver).toHaveBeenCalledWith("observer-a"); + fireEvent.click(screen.getByRole("button", { name: "Open packet" })); + expect(handlers.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); + }); + + 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 }); + expect(await screen.findByText("Packet details are unavailable or expired.")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Open packet" })).not.toBeInTheDocument(); + }); + + it("keeps an unavailable node removable and movable", async () => { + vi.mocked(api.getNode).mockRejectedValue(new Error("down")); + mount({ first: true }); + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load"); + expect(screen.getByRole("button", { name: "Move earlier" })).toBeDisabled(); + fireEvent.click(screen.getByRole("button", { name: "Move later" })); + expect(handlers.onMove).toHaveBeenCalledWith(1); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + expect(handlers.onRemove).toHaveBeenCalled(); + expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument(); + }); +}); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 210f35c1..ae2f6b3f 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -1,171 +1,231 @@ import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; -import { act, fireEvent, render, screen, within } from "@testing-library/react"; +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 * as api from "../../../src/api/client"; +import { ApiError } from "../../../src/api/client"; import type { Node } from "../../../src/features/nodes/types"; +import { formatHex } from "../../../src/lib/formatters"; import type { PacketDetail } from "../../../src/types/api"; import i18n from "../../../src/i18n"; -vi.mock("../../../src/api/client", () => ({ getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn(), getPacketDetail: vi.fn() })); +vi.mock("../../../src/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + getNodesPage: vi.fn(), + getNode: vi.fn(), + getNodeObservations: vi.fn(), + getPacketDetail: vi.fn(), +})); + const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Second" }; 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 clients: QueryClient[] = []; + function mount(active = true) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); return render(); } -function save(nodes = [pin]) { localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range: "24h" })); } +function save(nodes = [pin], range = "24h") { + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, 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); + beforeEach(() => { - vi.clearAllMocks(); localStorage.clear(); + vi.clearAllMocks(); + localStorage.clear(); vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null }); - vi.mocked(api.getNode).mockImplementation(async id => id === second.id ? { ...node, ...second } : node); + vi.mocked(api.getNode).mockImplementation(async (id) => (id === second.id ? { ...node, ...second } : node)); vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 7 }); vi.mocked(api.getPacketDetail).mockResolvedValue({ packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [ { observerId: "observer-a", observerName: "Home listener", id: 1, heardAt: 1, iata: "YKF" }, - { 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 PacketDetail); + ] } as unknown as PacketDetail); +}); +afterEach(() => { + clients.splice(0).forEach((client) => client.clear()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); -afterEach(() => { clients.splice(0).forEach(client => client.clear()); vi.restoreAllMocks(); }); describe("My Atlas", () => { - it("shows packet activity and signal charts without telemetry panels", async () => { - save(); mount(); - const card = await screen.findByRole("article", { name: "Renamed repeater" }); - await within(card).findByRole("button", { name: "Open node" }); - expect(within(card).getByRole("img", { name: /received reports/i })).toBeInTheDocument(); - expect(within(card).getByRole("meter", { name: "Mean SNR: 0 dB" })).toBeInTheDocument(); - expect(within(card).queryByText(/battery|temperature|humidity|telemetry/i)).not.toBeInTheDocument(); + it("renders saved nodes as Signal-style cards and nothing else before a search", async () => { + save(); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + expect(screen.getByText("Observations")).toBeInTheDocument(); + expect(screen.getByText("1 / 12 saved")).toBeInTheDocument(); + expect(screen.getByRole("group", { name: "Activity window" })).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + expect(api.getPacketDetail).not.toHaveBeenCalled(); expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); }); - it("does not scan nodes or packets before a search or selection", () => { + it("shows the empty state and queries nothing with no pins", () => { mount(); - expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); - expect(api.getNodesPage).not.toHaveBeenCalled(); + expect(screen.getByText("No nodes saved")).toBeInTheDocument(); expect(api.getNode).not.toHaveBeenCalled(); - expect(api.getPacketDetail).not.toHaveBeenCalled(); + expect(api.getNodesPage).not.toHaveBeenCalled(); }); - it("adds a search result once, persists it across visits, and displays its current name", async () => { + + it("adds a search result once, persists it and shows its current name", async () => { + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: true, nextCursor: null }); const view = mount(); - fireEvent.change(screen.getByRole("searchbox"), { target: { value: "repeater" } }); + typeSearch("repeater"); fireEvent.click(await screen.findByRole("button", { name: "Add Renamed repeater" })); - expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); + expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); + expect(screen.getByText("Showing the first 8 matches; refine the search for more.")).toBeInTheDocument(); expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); - view.unmount(); mount(); - expect(await screen.findByRole("article", { name: "Renamed repeater" })).toBeInTheDocument(); - expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { name: "repeater", limit: 8 }, expect.any(AbortSignal)); + view.unmount(); + mount(); + expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); }); - it("loads heard-by details only on demand, deduplicates observers and links to the evidence", async () => { - save(); mount(); - await screen.findByRole("button", { name: "Open node" }); - expect(api.getPacketDetail).not.toHaveBeenCalled(); - fireEvent.click(screen.getByRole("button", { name: "Heard by" })); - expect(await screen.findByText("2 reporting observers")).toBeInTheDocument(); - expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); - fireEvent.click(screen.getByRole("button", { name: "Home listener" })); expect(actions.onViewObserver).toHaveBeenCalledWith("observer-a"); - fireEvent.click(screen.getByRole("button", { name: "Open packet and observed paths" })); expect(actions.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); - fireEvent.click(screen.getByRole("button", { name: "Open node" })); expect(actions.onViewNode).toHaveBeenCalledWith(pin.id); - expect(screen.getByRole("meter", { name: "Mean SNR: 0 dB" })).toHaveAttribute("aria-valuenow", "0"); - }); - it("keeps only one heard-by section open and saves reordering, removal and the raw-history range", async () => { - save([pin, second]); mount(); - const first = await screen.findByRole("article", { name: "Renamed repeater" }); - const next = await screen.findByRole("article", { name: "Second" }); + + it("tells two results with the same name and type apart by their key prefix", async () => { + const twin = { ...node, id: second.id, publicKey: second.publicKey }; + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node, twin], hasMore: false, nextCursor: null }); + mount(); + typeSearch("repeater"); + expect(await screen.findAllByRole("button", { name: "Add Renamed repeater" })).toHaveLength(2); + expect(screen.getByText(formatHex(pin.publicKey))).toBeInTheDocument(); + expect(screen.getByText(formatHex(second.publicKey))).toBeInTheDocument(); + }); + + it("explains why a short or non-hex search shows nothing", async () => { + mount(); + expect(screen.getByText(/More information:.*hex, at least 2 characters/)).toBeInTheDocument(); + typeSearch("r"); + expect(await screen.findByText("Type at least 2 characters.")).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); + + it("never sends a non-hex public-key search", async () => { + mount(); + fireEvent.click(screen.getByRole("button", { name: /Name/ })); + fireEvent.click(screen.getByRole("button", { name: "Public Key" })); + typeSearch("not-a-key"); + expect(await screen.findByText("A public key prefix is hex only (0-9, a-f).")).toBeInTheDocument(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + typeSearch("abcd"); + await screen.findByRole("button", { name: "Add Renamed repeater" }); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: "abcd", limit: 8 }, expect.any(AbortSignal)); + }); + + it("clears the box when the search field changes, as the Nodes table does", async () => { + mount(); + typeSearch("repeater"); + await screen.findByRole("button", { name: "Add Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: /Name/ })); + fireEvent.click(screen.getByRole("button", { name: "Public Key" })); + expect(screen.getByRole("textbox", { name: "Find a node" })).toHaveValue(""); + expect(screen.queryByRole("button", { name: "Add Renamed repeater" })).not.toBeInTheDocument(); + }); + + it("refuses a thirteenth pin from the UI", async () => { + const pins = Array.from({ length: 12 }, (_, i) => ({ ...pin, id: `${i.toString().padStart(8, "0")}-1111-4111-8111-111111111111`, publicKey: i.toString(16).padStart(64, "0") })); + save(pins); + mount(); + expect(await screen.findByText("12 / 12 saved")).toBeInTheDocument(); + typeSearch("repeater"); + // twelve cards mount before the debounced search resolves; allow for a loaded suite + expect(await screen.findByRole("button", { name: "Add Renamed repeater" }, { timeout: 4000 })).toBeDisabled(); + expect(screen.getByText(/All 12 slots are in use/)).toBeInTheDocument(); + }); + + it("keeps one heard-by open, saves reordering, removal and the range", async () => { + save([pin, second]); + mount(); + const first = await card("Renamed repeater"); + const next = await card("Second"); 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-expanded", "false"); - fireEvent.click(within(first).getByText("Manage card")); + expect(within(next).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-expanded", "true"); fireEvent.click(within(first).getByRole("button", { name: "Move later" })); expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); - fireEvent.click(screen.getByRole("button", { name: "3 days" })); + 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"); - fireEvent.click(within(first).getByRole("button", { name: "Remove card" })); - expect(screen.queryByRole("article", { name: "Renamed repeater" })).not.toBeInTheDocument(); - expect(screen.queryByRole("button", { name: /7 days|30 days/ })).not.toBeInTheDocument(); + fireEvent.click(within(first).getByRole("button", { name: "Remove" })); + expect(screen.queryByRole("button", { name: "Renamed repeater" })).not.toBeInTheDocument(); }); + it("uses French labels and keeps working when browser writes fail", async () => { - save(); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("quota"); }); - await act(() => i18n.changeLanguage("fr")); mount(); - await screen.findByRole("article", { name: "Renamed repeater" }); - fireEvent.click(screen.getByRole("button", { name: "3 jours" })); - expect(screen.getByRole("button", { name: "3 jours" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByText(/L’enregistrement dans le navigateur est indisponible/)).toBeInTheDocument(); + const store = new Map([[ATLAS_KEY, JSON.stringify({ version: 1, nodes: [pin], range: "24h" })]]); + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: () => { throw new Error("quota"); }, + removeItem: (key: string) => store.delete(key), + clear: () => store.clear(), + }); + await act(() => i18n.changeLanguage("fr")); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + fireEvent.click(screen.getByRole("button", { name: "3 j" })); + expect(screen.getByRole("button", { name: "3 j" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText(/Le stockage du navigateur est indisponible/)).toBeInTheDocument(); + expect(screen.getByText("1 / 12 enregistré")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Entendu par" })).toBeInTheDocument(); }); - it("honours changes made in another tab and keeps corrupt storage from crashing the page", async () => { - localStorage.setItem(ATLAS_KEY, "{"); mount(); + + it("honours changes made in another tab and survives corrupt storage", async () => { + localStorage.setItem(ATLAS_KEY, "{"); + mount(); act(() => window.dispatchEvent(new StorageEvent("storage", { key: ATLAS_KEY, newValue: JSON.stringify({ version: 1, nodes: [pin], range: "3d" }) }))); - await screen.findByRole("article", { name: "Renamed repeater" }); - expect(screen.getByRole("button", { name: "3 days" })).toHaveAttribute("aria-pressed", "true"); + 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("Build your own view of the mesh")).toBeInTheDocument(); + expect(screen.getByText("No nodes saved")).toBeInTheDocument(); }); + it("does not query a retained but inactive page", () => { - save(); mount(false); - expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodeObservations).not.toHaveBeenCalled(); - }); - it("explains expired packet evidence", async () => { - save(); vi.mocked(api.getPacketDetail).mockRejectedValue(new Error("expired")); mount(); - fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); - await screen.findByText(/The packet details are unavailable or have expired/); - }); - it("keeps an unavailable node removable without rendering zeros as real readings", async () => { - save(); vi.mocked(api.getNode).mockRejectedValue(new Error("unavailable")); mount(); - await screen.findByText(/This saved node could not be loaded/); - expect(screen.queryByRole("meter")).not.toBeInTheDocument(); - fireEvent.click(screen.getByText("Manage card")); - fireEvent.click(screen.getByRole("button", { name: "Remove card" })); - expect(screen.getByText("Build your own view of the mesh")).toBeInTheDocument(); - }); - it("does not expose observer links for a different full origin identity", async () => { - save(); vi.mocked(api.getPacketDetail).mockResolvedValue({ originPubkey: second.publicKey, observations: [] } as unknown as PacketDetail); mount(); - fireEvent.click(await screen.findByRole("button", { name: "Heard by" })); - await screen.findByText(/The packet details are unavailable or have expired/); - expect(screen.queryByRole("button", { name: "Open packet and observed paths" })).not.toBeInTheDocument(); + save(); + mount(false); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getNodeObservations).not.toHaveBeenCalled(); }); + it("aborts in-flight requests when leaving the page", () => { - save(); vi.mocked(api.getNode).mockImplementation(() => new Promise(() => {})); - const view = mount(); const signal = vi.mocked(api.getNode).mock.calls[0]![1]!; + save(); + vi.mocked(api.getNode).mockImplementation(() => new Promise(() => {})); + const view = mount(); + const signal = vi.mocked(api.getNode).mock.calls[0]![1]!; expect(signal.aborted).toBe(false); - view.unmount(); expect(signal.aborted).toBe(true); - }); - it("shows missing signal values without meters for an empty retained sample", async () => { - save(); vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); mount(); - await screen.findByRole("button", { name: "Open node" }); - expect(screen.getByRole("img", { name: "Mean SNR: Unavailable" })).toBeInTheDocument(); - expect(screen.queryByRole("meter")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Heard by" })); - expect(api.getPacketDetail).not.toHaveBeenCalled(); - }); - it("never queries an invalid public-key search as an unfiltered node list", async () => { - mount(); fireEvent.click(screen.getByRole("button", { name: /Search.*Name/ })); - fireEvent.click(screen.getByRole("option", { name: "Public key" })); - fireEvent.change(screen.getByRole("searchbox"), { target: { value: "not-a-key" } }); - await act(() => new Promise(resolve => setTimeout(resolve, 350))); - expect(api.getNodesPage).not.toHaveBeenCalled(); + view.unmount(); + expect(signal.aborted).toBe(true); }); }); describe("identity recovery", () => { it("recovers a changed server ID using the exact full public key", async () => { - vi.mocked(api.getNode).mockRejectedValueOnce(Object.assign(new Error("missing"), { status: 404 })); + vi.mocked(api.getNode).mockRejectedValueOnce(new ApiError(404, "not_found", "missing")); vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, id: second.id }], nextCursor: null, hasMore: false }); vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, id: second.id }); const result = await loadAtlasNode(pin); expect(result.node.id).toBe(second.id); expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, undefined); }); + it("writes a recovered server ID back so later polls skip the lookup", async () => { + vi.mocked(api.getNode).mockImplementation(async (id) => { + if (id === pin.id) throw new ApiError(404, "not_found", "missing"); + return { ...node, id: second.id }; + }); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, id: second.id }], nextCursor: null, hasMore: false }); + save(); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[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 }); vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); - await expect(loadAtlasNode(pin)).rejects.toThrow("Atlas identity unavailable"); + await expect(loadAtlasNode(pin)).rejects.toMatchObject({ message: "Atlas identity unavailable", status: 404 }); expect(api.getNodeObservations).not.toHaveBeenCalled(); vi.mocked(api.getNodesPage).mockClear(); vi.mocked(api.getNode).mockRejectedValueOnce(new Error("network")); await expect(loadAtlasNode(pin)).rejects.toThrow("network"); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts index cee540a8..e82e7793 100644 --- a/tests/features/atlas/atlas.test.ts +++ b/tests/features/atlas/atlas.test.ts @@ -20,30 +20,65 @@ describe("saved Atlas", () => { }); describe("origin report sample", () => { - it("aligns signal charts with the activity window and preserves missing hours", () => { - const sample = summarizeReports([report(1, 0, { snr: 0, rssi: -100 }), report(2, 2, { snr: 8, rssi: -90 }), report(3, 24, { snr: 20, rssi: -70 })], "24h", now); - expect(sample.snrBins[23]).toBe(0); - expect(sample.rssiBins[23]).toBe(-100); - expect(sample.snrBins[22]).toBe(8); - expect(sample.snrBins[21]).toBeNull(); - expect(sample.snrBins[0]).toBeNull(); - expect(sample.bins[23]).toBe(1); - }); - - it("bins the selected window, excludes future/old reports, includes other payload types and deduplicates IDs", () => { - const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, -1), report(5, 1, { payloadType: 99 }), report(1, 0)]; + it("counts a report newer than the tick snapshot in the latest hour", () => { + const sample = summarizeReports([report(1, 0), report(2, -0.01)], "24h", now); + expect(sample.reports.map((r) => r.id)).toEqual([2, 1]); + expect(sample.counts).toHaveLength(24); + expect(sample.counts[23]).toBe(2); + }); + + it("bins the window inclusive of its start, drops older reports, keeps other payload types and dedupes ids", () => { + const rows = [report(1, 0), report(2, 23.9), report(3, 24), report(4, 24.01), report(5, 1, { payloadType: 99 }), report(1, 0)]; const sample = summarizeReports(rows, "24h", now); - expect(sample.reports.map(r => r.id)).toEqual([1, 5, 2]); - expect(sample.bins.reduce((a, b) => a + b, 0)).toBe(3); - expect(sample.bins[23]).toBe(2); - expect(sample.bins[0]).toBe(1); - expect(summarizeReports(rows, "3d", now).reports).toHaveLength(4); + expect(sample.reports.map((r) => r.id)).toEqual([1, 5, 2, 3]); + expect(sample.counts.reduce((a, b) => a + b, 0)).toBe(4); + expect(sample.counts[23]).toBe(2); + expect(sample.counts[0]).toBe(2); + expect(sample.present[0]).toBe(true); + expect(sample.present[12]).toBe(false); + expect(summarizeReports(rows, "3d", now).counts).toHaveLength(72); + expect(summarizeReports(rows, "3d", now).reports).toHaveLength(5); }); + + it("gives hourly means per bin and null where the hour has no signal sample", () => { + const sample = summarizeReports([report(1, 0, { snr: 0, rssi: -100 }), report(2, 2, { snr: 8, rssi: -90 }), report(3, 24.01, { snr: 20, rssi: -70 })], "24h", now); + expect(sample.snrHourly[23]).toBe(0); + expect(sample.rssiHourly[23]).toBe(-100); + expect(sample.snrHourly[22]).toBe(8); + expect(sample.snrHourly[21]).toBeNull(); + expect(sample.snrHourly[0]).toBeNull(); + }); + it("does not turn missing signal into zero and averages real zero SNR samples", () => { const sample = summarizeReports([report(1, 1), report(2, 2, { rssi: 0, snr: 0 }), report(3, 3, { rssi: -110, snr: 0, hopCount: 0 }), report(4, 4, { rssi: -90, snr: 10, hopCount: 4 }), report(5, 5, { rssi: NaN, snr: Infinity })], "24h", now); expect(sample.snr).toBe(5); expect(sample.rssi).toBe(-100); expect(sample.hops).toBe(2); expect(summarizeReports([], "24h", now).snr).toBeNull(); + expect(summarizeReports([], "24h", now).counts).toHaveLength(24); + }); + + it("starts the bins at the oldest loaded hour when the page was capped", () => { + const rows = [report(1, 0.5), report(2, 1.5), report(3, 5.2)]; + const full = summarizeReports(rows, "3d", now, false); + expect(full.counts).toHaveLength(72); + expect(full.since).toBe(now - 72 * 3_600_000); + const capped = summarizeReports(rows, "3d", now, true); + expect(capped.since).toBe(Math.floor((now - 5.2 * 3_600_000) / 3_600_000) * 3_600_000); + expect(capped.counts).toHaveLength(6); + expect(capped.counts[0]).toBe(1); + expect(capped.counts.reduce((a, b) => a + b, 0)).toBe(3); + expect(capped.truncated).toBe(true); + expect(full.truncated).toBe(false); + expect(summarizeReports([], "3d", now, true).counts).toHaveLength(72); + }); + + it("is not truncated when more rows exist but the loaded page already reaches past the window", () => { + const rows = [report(1, 2), report(2, 30), report(3, 100)]; + const sample = summarizeReports(rows, "24h", now, true); + expect(sample.truncated).toBe(false); + expect(sample.since).toBe(now - 24 * 3_600_000); + expect(sample.counts).toHaveLength(24); + expect(sample.reports.map((r) => r.id)).toEqual([1]); }); }); diff --git a/tests/features/atlas/useAtlas.test.tsx b/tests/features/atlas/useAtlas.test.tsx new file mode 100644 index 00000000..e15b454d --- /dev/null +++ b/tests/features/atlas/useAtlas.test.tsx @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook, waitFor } 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 { noteRateLimited, noteRequestOk, shouldRetryQuery } from "../../../src/api/rate-limit"; +import { ATLAS_REFRESH_MS, useAtlasNode } from "../../../src/features/atlas/useAtlas"; +import type { Node } from "../../../src/features/nodes/types"; + +vi.mock("../../../src/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + getNode: vi.fn(), + getNodeObservations: vi.fn(), + getNodesPage: vi.fn(), +})); + +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Pinned" }; +const node = { ...pin, nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 1, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node; +const report = { id: 1, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 4, rssi: -90, hopCount: 1 }; +// The app's retry policy and back-off (1 s, then 2 s) so these tests exercise what ships. +const RETRY_MS = 3000; +let client: QueryClient; +const wrapper = ({ children }: { children: ReactNode }) => {children}; +const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); +// Reads the status during render, as the card does, so the tracked query re-renders on an error. +const useProbe = () => { + const value = useAtlasNode(pin, "24h", true); + return { ...value, status: value.query.status }; +}; +const failNext = (times: number) => { + for (let i = 0; i < times; i++) vi.mocked(api.getNode).mockRejectedValueOnce(new Error("down")); +}; + +beforeEach(() => { + client = new QueryClient({ defaultOptions: { queries: { retry: shouldRetryQuery } } }); + vi.clearAllMocks(); + vi.mocked(api.getNode).mockResolvedValue(node); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [report], hasMore: true, nextCursor: 1 }); +}); +afterEach(() => { + client.clear(); + noteRequestOk(); + vi.useRealTimers(); +}); + +describe("atlas polling policy", () => { + it("retries a transient failure twice, then keeps polling", async () => { + vi.useFakeTimers(); + failNext(3); + const { result } = renderHook(useProbe, { wrapper }); + await tick(10); + expect(result.current.status).toBe("pending"); + await tick(RETRY_MS); + expect(result.current.status).toBe("error"); + expect(api.getNode).toHaveBeenCalledTimes(3); + await tick(ATLAS_REFRESH_MS); + expect(result.current.node).toBeDefined(); + expect(api.getNode).toHaveBeenCalledTimes(4); + }); + + it("keeps the last good sample while a later poll fails, then recovers on the next", async () => { + vi.useFakeTimers(); + const { result } = renderHook(useProbe, { wrapper }); + await tick(10); + expect(result.current.node).toBeDefined(); + failNext(3); + await tick(ATLAS_REFRESH_MS + RETRY_MS); + expect(result.current.status).toBe("error"); + expect(result.current.node).toBeDefined(); + expect(result.current.sample.reports).toHaveLength(1); + expect(api.getNode).toHaveBeenCalledTimes(4); + await tick(ATLAS_REFRESH_MS); + expect(result.current.status).toBe("success"); + expect(api.getNode).toHaveBeenCalledTimes(5); + }); + + it("does not retry when the server says the node is gone", async () => { + vi.useFakeTimers(); + vi.mocked(api.getNode).mockRejectedValue(new ApiError(404, "not_found", "missing")); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); + const { result } = renderHook(useProbe, { wrapper }); + await tick(RETRY_MS); + expect(result.current.status).toBe("error"); + expect(api.getNode).toHaveBeenCalledTimes(1); + expect(api.getNodesPage).toHaveBeenCalledTimes(1); + }); + + it("pauses the poll while the API is throttling and resumes once the server is answering again", async () => { + vi.useFakeTimers(); + const { result } = renderHook(() => useAtlasNode(pin, "24h", true), { wrapper }); + await tick(10); + expect(result.current.node).toBeDefined(); + act(() => noteRateLimited(5 * ATLAS_REFRESH_MS)); + await tick(2 * ATLAS_REFRESH_MS); + expect(api.getNode).toHaveBeenCalledTimes(1); + act(() => noteRequestOk()); + await tick(10); + expect(api.getNode).toHaveBeenCalledTimes(2); + }); + + it("resumes on its own once the throttle window has expired", async () => { + vi.useFakeTimers(); + renderHook(() => useAtlasNode(pin, "24h", true), { wrapper }); + await tick(10); + act(() => noteRateLimited(2 * ATLAS_REFRESH_MS)); + await tick(2 * ATLAS_REFRESH_MS); + expect(api.getNode).toHaveBeenCalledTimes(1); + await tick(ATLAS_REFRESH_MS); + expect(api.getNode).toHaveBeenCalledTimes(2); + }); +}); + +describe("useAtlasNode", () => { + it("loads the node and summarises its reports, flagging a capped page", async () => { + const { result } = renderHook(() => useAtlasNode(pin, "24h", true), { wrapper }); + await waitFor(() => expect(result.current.node).toBeDefined()); + expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); + expect(result.current.sample.reports).toHaveLength(1); + expect(result.current.sample.snr).toBe(4); + expect(result.current.capped).toBe(true); + }); + + it("does nothing while inactive and returns an empty sample", () => { + const { result } = renderHook(() => useAtlasNode(pin, "3d", false), { wrapper }); + expect(api.getNode).not.toHaveBeenCalled(); + expect(result.current.node).toBeUndefined(); + expect(result.current.sample.counts).toHaveLength(72); + expect(result.current.capped).toBe(false); + }); +}); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index 6255e2f4..831550c2 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -1,6 +1,15 @@ import { expect, it } from "vitest"; -import { render } from "@testing-library/react"; -import { StatCard } from "../../../src/features/stats/cards"; +import { render, screen } from "@testing-library/react"; +import { Card, StatCard } from "../../../src/features/stats/cards"; + +it("keeps the header on one line unless a caller asks for the right slot to wrap", () => { + const view = render(Act}>body); + const header = screen.getByRole("button", { name: "Act" }).parentElement!; + expect(header).not.toHaveClass("flex-wrap"); + view.unmount(); + render(Act}>body); + expect(screen.getByRole("button", { name: "Act" }).parentElement).toHaveClass("flex-wrap"); +}); it("marks the peak hour on a sparkline when asked", () => { const { container } = render(); diff --git a/tests/features/stats/signal.test.ts b/tests/features/stats/signal.test.ts index 0e2288b6..90a469fa 100644 --- a/tests/features/stats/signal.test.ts +++ b/tests/features/stats/signal.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { signalHours, signalBinLabel, signalHistogramOption, signalTrendOption, signalCoverageOption } from "../../../src/features/stats/signal"; +import { average, signalHours, signalBinLabel, signalHistogramOption, signalTrendOption, signalCoverageOption } from "../../../src/features/stats/signal"; import { readChartColors } from "../../../src/features/stats/chartTheme"; import type { SignalStats } from "../../../src/features/stats/types"; import i18n from "../../../src/i18n"; @@ -58,3 +58,13 @@ describe("signal analytics", () => { expect(JSON.stringify(signalFixture)).toBe(before); }); }); + +describe("average", () => { + it("formats one decimal with the unit and a dash for no sample", () => { + expect(average(null)).toBe("—"); + expect(average(undefined, "dB")).toBe("—"); + expect(average(0, "dB")).toBe("0.0 dB"); + expect(average(-102.46, "dBm")).toBe("-102.5 dBm"); + expect(average(2.04)).toBe("2.0"); + }); +}); diff --git a/tests/lib/constants.test.ts b/tests/lib/constants.test.ts index 538dd1cc..505a500b 100644 --- a/tests/lib/constants.test.ts +++ b/tests/lib/constants.test.ts @@ -21,10 +21,10 @@ describe("filterEnabledTabs", () => { it("excludes named tabs case-insensitively", () => { expect(filterEnabledTabs(TABS, "map,nodes")).toEqual([ - "MyAtlas", "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "MyAtlas", "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas", ]); }); From 288a58d286d595e3e3f8d0eb1f874ef63f87dd83 Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Mon, 12 Oct 2026 13:50:25 -0400 Subject: [PATCH 05/10] Atlas: drag cards to reorder and flip Heard by in place --- src/features/atlas/AtlasCard.tsx | 129 +++++++++++----------- src/features/atlas/MyAtlasPage.tsx | 21 ++-- src/features/atlas/useCardDrag.ts | 90 +++++++++++++++ src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- tests/features/atlas/AtlasCard.test.tsx | 26 +++-- tests/features/atlas/MyAtlasPage.test.tsx | 6 +- tests/features/atlas/useCardDrag.test.tsx | 84 ++++++++++++++ 8 files changed, 272 insertions(+), 90 deletions(-) create mode 100644 src/features/atlas/useCardDrag.ts create mode 100644 tests/features/atlas/useCardDrag.test.tsx diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 54f5b3d2..1f81c2ca 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { isNotFound } from "../../api/client"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; @@ -14,6 +14,7 @@ import { useChartColors } from "../stats/chartTheme"; import { average } from "../stats/signal"; import { REPORT_LIMIT, type AtlasPin, type AtlasRange } from "./atlas"; import { useAtlasNode } from "./useAtlas"; +import type { CardHandleProps } from "./useCardDrag"; export interface AtlasActions { onViewNode: (id: string) => void; @@ -28,10 +29,8 @@ interface AtlasCardProps extends AtlasActions { heardOpen: boolean; onHeardToggle: () => void; onRemove: () => void; - onMove: (direction: -1 | 1) => void; onResolved: (id: string) => void; - first: boolean; - last: boolean; + handle: Partial; } // One pinned node, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way. @@ -42,10 +41,8 @@ export function AtlasCard({ heardOpen, onHeardToggle, onRemove, - onMove, onResolved, - first, - last, + handle, onViewNode, onViewObserver, onAnalyzePacket, @@ -77,6 +74,14 @@ export function AtlasCard({ const hours = sample.counts.length; const activeHours = sample.present.filter(Boolean).length; + // The list view holds the tiles' height so flipping never resizes the card. + const body = useRef(null); + const [held, setHeld] = useState(); + const toggleHeard = () => { + if (!heardOpen) setHeld(body.current?.offsetHeight); + onHeardToggle(); + }; + const title = ( - -
); @@ -144,8 +143,8 @@ export function AtlasCard({

)} - {node && ( -
+ {node && !heardOpen && ( +
{t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}
- {heardOpen && ( -
- {!latest ? ( -

{t("atlas.noReports")}

- ) : heard.isPending ? ( -

{t("common.loading")}

- ) : heard.isError || !sameOrigin ? ( -

{t("atlas.heardUnavailable")}

- ) : ( - <> -

- {t("atlas.observers", { count: observers.length })} -

-
    - {observers.map((row) => ( -
  • - - {row.iata} -
  • - ))} -
- - - )} -
+
+ )} + {node && heardOpen && ( +
+ {!latest ? ( +

{t("atlas.noReports")}

+ ) : heard.isPending ? ( +

{t("common.loading")}

+ ) : heard.isError || !sameOrigin ? ( +

{t("atlas.heardUnavailable")}

+ ) : ( + <> +

+ {t("atlas.observers", { count: observers.length })} +

+
    + {observers.map((row) => ( +
  • + + {row.iata} +
  • + ))} +
+ + )}
)} diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index cb86eff2..b6e6271e 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -15,6 +15,7 @@ import { Card } from "../stats/cards"; import { Segmented } from "../stats/Segmented"; import { AtlasCard, type AtlasActions } from "./AtlasCard"; import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas"; +import { useCardDrag } from "./useCardDrag"; import { useSavedAtlas } from "./useSavedAtlas"; const RANGES: AtlasRange[] = ["24h", "3d"]; @@ -66,15 +67,19 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti update((value) => ({ ...value, nodes: value.nodes.filter((node) => node.publicKey !== publicKey) })); const relink = (publicKey: string, id: string) => update((value) => ({ ...value, nodes: value.nodes.map((node) => (node.publicKey === publicKey ? { ...node, id } : node)) })); - const move = (publicKey: string, direction: -1 | 1) => + const reorder = (from: number, to: number) => update((value) => { const nodes = [...value.nodes]; - const from = nodes.findIndex((node) => node.publicKey === publicKey); - const to = from + direction; - if (from < 0 || to < 0 || to >= nodes.length) return value; - [nodes[from], nodes[to]] = [nodes[to]!, nodes[from]!]; + const [moved] = nodes.splice(from, 1); + if (!moved || to < 0 || to >= value.nodes.length) return value; + nodes.splice(to, 0, moved); return { ...value, nodes }; }); + const drag = useCardDrag(saved.nodes.length, reorder); + const cardProps = (index: number) => { + const props = drag.cardProps(index); + return { ...props, className: `min-w-0 ${props.className ?? ""}` }; + }; return (
@@ -164,18 +169,16 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti ) : (
{saved.nodes.map((pin, index) => ( -
+
setHeardKey((key) => (key === pin.publicKey ? null : pin.publicKey))} onRemove={() => remove(pin.publicKey)} - onMove={(direction) => move(pin.publicKey, direction)} onResolved={(id) => relink(pin.publicKey, id)} + handle={drag.handleProps(index)} {...actions} />
diff --git a/src/features/atlas/useCardDrag.ts b/src/features/atlas/useCardDrag.ts new file mode 100644 index 00000000..378779df --- /dev/null +++ b/src/features/atlas/useCardDrag.ts @@ -0,0 +1,90 @@ +import { useCallback, useEffect, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react"; + +interface Drag { + from: number; + over: number; + dx: number; + dy: number; + rects: DOMRect[]; +} + +// Pointer-driven reordering for a grid of cards: the grabbed card follows the pointer and the cards +// between its old and new slot slide into the neighbouring rect, so a drop lands where it looks. +export function useCardDrag(count: number, onReorder: (from: number, to: number) => void) { + const cards = useRef(new Map()); + const [drag, setDrag] = useState(null); + const cleanup = useRef<() => void>(() => {}); + + useEffect(() => () => cleanup.current(), []); + + const start = useCallback((index: number, event: PointerEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + const rects = Array.from({ length: count }, (_, i) => cards.current.get(i)?.getBoundingClientRect() ?? new DOMRect()); + const origin = { x: event.clientX, y: event.clientY }; + let current: Drag = { from: index, over: index, dx: 0, dy: 0, rects }; + setDrag(current); + + const onMove = (e: globalThis.PointerEvent) => { + const hit = rects.findIndex((r) => e.clientX >= r.left && e.clientX < r.right && e.clientY >= r.top && e.clientY < r.bottom); + current = { ...current, dx: e.clientX - origin.x, dy: e.clientY - origin.y, over: hit === -1 ? current.over : hit }; + setDrag(current); + }; + const end = (commit: boolean) => { + cleanup.current(); + setDrag(null); + if (commit && current.over !== current.from) onReorder(current.from, current.over); + }; + const onUp = () => end(true); + const onCancel = () => end(false); + const onKey = (e: globalThis.KeyboardEvent) => { + if (e.key === "Escape") end(false); + }; + window.addEventListener("pointermove", onMove); + window.addEventListener("pointerup", onUp); + window.addEventListener("pointercancel", onCancel); + window.addEventListener("keydown", onKey); + cleanup.current = () => { + window.removeEventListener("pointermove", onMove); + window.removeEventListener("pointerup", onUp); + window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("keydown", onKey); + cleanup.current = () => {}; + }; + }, [count, onReorder]); + + const style = (index: number): CSSProperties | undefined => { + if (!drag) return undefined; + const { from, over, dx, dy, rects } = drag; + if (index === from) return { transform: `translate(${dx}px, ${dy}px)`, zIndex: 10, position: "relative" }; + // Cards between the two slots take the place of their neighbour on the side the card came from. + const shift = from < index && index <= over ? -1 : over <= index && index < from ? 1 : 0; + if (shift === 0) return undefined; + const to = rects[index + shift]!; + const own = rects[index]!; + return { transform: `translate(${to.left - own.left}px, ${to.top - own.top}px)` }; + }; + + return { + dragging: drag?.from ?? null, + cardProps: (index: number) => ({ + ref: (el: HTMLElement | null) => { + if (el) cards.current.set(index, el); + else cards.current.delete(index); + }, + style: style(index), + className: drag && drag.from !== index ? "transition-transform duration-150" : undefined, + }), + handleProps: (index: number) => ({ + onPointerDown: (event: PointerEvent) => start(index, event), + onKeyDown: (event: KeyboardEvent) => { + const to = event.key === "ArrowLeft" ? index - 1 : event.key === "ArrowRight" ? index + 1 : null; + if (to === null || to < 0 || to >= count) return; + event.preventDefault(); + onReorder(index, to); + }, + }), + }; +} + +export type CardHandleProps = ReturnType["handleProps"]>; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 5012799f..1f7f8978 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -955,8 +955,7 @@ "noReports": "No observations in this window.", "heardUnavailable": "Packet details are unavailable or expired.", "openPacket": "Open packet", - "moveEarlier": "Move earlier", - "moveLater": "Move later", + "dragToReorder": "Drag to reorder", "remove": "Remove" } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 2f52cdc1..e6c5b302 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -993,8 +993,7 @@ "noReports": "Aucune observation sur cette période.", "heardUnavailable": "Détails du paquet indisponibles ou expirés.", "openPacket": "Ouvrir le paquet", - "moveEarlier": "Déplacer avant", - "moveLater": "Déplacer après", + "dragToReorder": "Glisser pour réordonner", "remove": "Retirer" } } diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index b78782f9..1b78c22c 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -30,7 +30,6 @@ const handlers = { onAnalyzePacket: vi.fn(), onHeardToggle: vi.fn(), onRemove: vi.fn(), - onMove: vi.fn(), onResolved: vi.fn(), }; let client: QueryClient; @@ -38,7 +37,7 @@ let client: QueryClient; function mount(overrides: Partial[0]> = {}) { return render( - + , ); } @@ -125,23 +124,34 @@ describe("AtlasCard", () => { expect(screen.getByText("24h")).toBeInTheDocument(); }); - it("loads heard-by only when open, dedupes observers and links to the packet", async () => { + it("flips the body between the tiles and the heard-by list instead of growing the card", async () => { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { configurable: true, get: () => 240 }); const view = mount(); await screen.findByRole("button", { name: "Renamed repeater" }); 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"); + fireEvent.click(toggle); + expect(handlers.onHeardToggle).toHaveBeenCalled(); 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.queryByText("Mean SNR")).not.toBeInTheDocument(); + expect(summary.parentElement).toHaveStyle({ minHeight: "240px" }); expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); expect(screen.getAllByText("YKF")[0]).toHaveClass("bg-primary/6"); fireEvent.click(screen.getByRole("button", { name: "Home listener" })); expect(handlers.onViewObserver).toHaveBeenCalledWith("observer-a"); fireEvent.click(screen.getByRole("button", { name: "Open packet" })); expect(handlers.onAnalyzePacket).toHaveBeenCalledWith(report.packetHash, 7); + delete (HTMLElement.prototype as { offsetHeight?: unknown }).offsetHeight; }); it("hides observer links for a different origin identity and explains expired packets", async () => { @@ -151,13 +161,11 @@ describe("AtlasCard", () => { expect(screen.queryByRole("button", { name: "Open packet" })).not.toBeInTheDocument(); }); - it("keeps an unavailable node removable and movable", async () => { + it("keeps an unavailable node removable and draggable", async () => { vi.mocked(api.getNode).mockRejectedValue(new Error("down")); - mount({ first: true }); + mount({ handle: { "data-grip": "yes" } as Record }); expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load"); - expect(screen.getByRole("button", { name: "Move earlier" })).toBeDisabled(); - fireEvent.click(screen.getByRole("button", { name: "Move later" })); - expect(handlers.onMove).toHaveBeenCalledWith(1); + expect(screen.getByRole("button", { name: "Drag to reorder" })).toHaveAttribute("data-grip", "yes"); fireEvent.click(screen.getByRole("button", { name: "Remove" })); expect(handlers.onRemove).toHaveBeenCalled(); expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument(); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index ae2f6b3f..ff7a855f 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -145,9 +145,9 @@ describe("My Atlas", () => { const next = await card("Second"); 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-expanded", "false"); - expect(within(next).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-expanded", "true"); - fireEvent.click(within(first).getByRole("button", { name: "Move later" })); + expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "false"); + expect(within(next).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.keyDown(within(first).getByRole("button", { name: "Drag to reorder" }), { key: "ArrowRight" }); expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); fireEvent.click(screen.getByRole("button", { name: "3d" })); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); diff --git a/tests/features/atlas/useCardDrag.test.tsx b/tests/features/atlas/useCardDrag.test.tsx new file mode 100644 index 00000000..97b0a0a8 --- /dev/null +++ b/tests/features/atlas/useCardDrag.test.tsx @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { useCardDrag } from "../../../src/features/atlas/useCardDrag"; + +const onReorder = vi.fn(); + +// Three cards in one row, 100px apart, so pointer positions map to slots without layout. +function Grid() { + const drag = useCardDrag(3, onReorder); + return ( +
+ {[0, 1, 2].map((index) => ( +
+
+ ))} +
+ ); +} + +function mount() { + render(); + [0, 1, 2].forEach((index) => { + screen.getByTestId(`card-${index}`).getBoundingClientRect = () => + ({ left: index * 100, right: index * 100 + 100, top: 0, bottom: 100, width: 100, height: 100, x: index * 100, y: 0, toJSON() {} }) as DOMRect; + }); +} +const move = (x: number, y = 50) => fireEvent(window, new MouseEvent("pointermove", { clientX: x, clientY: y })); +const drop = () => fireEvent(window, new MouseEvent("pointerup")); + +beforeEach(() => vi.clearAllMocks()); + +describe("useCardDrag", () => { + it("moves the dragged card with the pointer and slides the others out of its way", () => { + mount(); + fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 }); + move(250); + expect(screen.getByTestId("card-0")).toHaveStyle({ transform: "translate(200px, 0px)" }); + expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(-100px, 0px)" }); + expect(screen.getByTestId("card-2")).toHaveStyle({ transform: "translate(-100px, 0px)" }); + drop(); + expect(onReorder).toHaveBeenCalledWith(0, 2); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + }); + + it("slides cards forward when dragging towards the start", () => { + mount(); + fireEvent.pointerDown(screen.getByLabelText("handle 2"), { clientX: 250, clientY: 50 }); + move(120); + expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(100px, 0px)" }); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + drop(); + expect(onReorder).toHaveBeenCalledWith(2, 1); + }); + + it("does nothing on a drop in the same slot or outside every card", () => { + mount(); + fireEvent.pointerDown(screen.getByLabelText("handle 1"), { clientX: 150, clientY: 50 }); + move(160); + move(160, 900); + drop(); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it("cancels on Escape", () => { + mount(); + fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 }); + move(250); + fireEvent.keyDown(window, { key: "Escape" }); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + drop(); + expect(onReorder).not.toHaveBeenCalled(); + }); + + it("moves one slot with the arrow keys on the handle, within bounds", () => { + mount(); + fireEvent.keyDown(screen.getByLabelText("handle 0"), { key: "ArrowLeft" }); + expect(onReorder).not.toHaveBeenCalled(); + fireEvent.keyDown(screen.getByLabelText("handle 0"), { key: "ArrowRight" }); + expect(onReorder).toHaveBeenCalledWith(0, 1); + fireEvent.keyDown(screen.getByLabelText("handle 2"), { key: "ArrowRight" }); + expect(onReorder).toHaveBeenCalledTimes(1); + }); +}); From a2fc5362cf880a193307dc096e5b4245cfcb49ff Mon Sep 17 00:00:00 2001 From: n30nex Date: Mon, 12 Oct 2026 14:45:51 -0400 Subject: [PATCH 06/10] Keep Atlas charts aligned with the upstream contrast-safe palette --- src/features/atlas/AtlasCard.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 1f81c2ca..c9d59df0 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -149,7 +149,7 @@ export function AtlasCard({
From 7400d2abacb8ccbd4626a523cebda5b5b68b1aaa Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 13 Oct 2026 06:11:43 -0400 Subject: [PATCH 07/10] Atlas: one-row phone toolbar, uniform card heights; Observers notes in a tip --- src/features/atlas/AtlasCard.tsx | 20 +++-- src/features/atlas/MyAtlasPage.tsx | 77 +++++++++++-------- src/features/observers/ObserverSidebar.tsx | 33 ++++---- src/features/stats/cards.tsx | 10 ++- src/i18n/locales/en.json | 2 + src/i18n/locales/fr.json | 2 + tests/features/atlas/AtlasCard.test.tsx | 13 +++- tests/features/atlas/MyAtlasPage.test.tsx | 27 +++++++ .../observers/ObserverSidebar.test.tsx | 15 +++- tests/features/stats/cards.test.tsx | 9 +++ 10 files changed, 148 insertions(+), 60 deletions(-) diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index c9d59df0..0e2f58e4 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -74,7 +74,7 @@ export function AtlasCard({ const hours = sample.counts.length; const activeHours = sample.present.filter(Boolean).length; - // The list view holds the tiles' height so flipping never resizes the card. + // 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 = () => { @@ -130,7 +130,7 @@ export function AtlasCard({ ); return ( - + {query.isPending &&

{t("common.loading")}

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

{t("nodeDetail.notFound")}

@@ -144,9 +144,10 @@ export function AtlasCard({

)} {node && !heardOpen && ( -
-
+
+
{t("atlas.meanHops")} {average(sample.hops)} - {t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })} - + + {t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}{" "} + +
)} {node && heardOpen && ( -
+
{!latest ? (

{t("atlas.noReports")}

) : heard.isPending ? ( diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index b6e6271e..f6e152ca 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -8,6 +8,7 @@ import { EmptyState } from "../../components/EmptyState"; import { InfoTip } from "../../components/InfoTip"; import { SearchBar, type SearchFieldOption } from "../../components/SearchBar"; import { Tooltip } from "../../components/Tooltip"; +import { useIsMobile } from "../../hooks/useMediaQuery"; import { formatHex } from "../../lib/formatters"; import { nodeSearchParams } from "../nodes/node-search"; import type { NodeSummary } from "../nodes/types"; @@ -26,6 +27,8 @@ const SEARCH_MIN = 2; export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) { const { t } = useTranslation(); const { saved, update, storageFailed } = useSavedAtlas(); + // Same single-row bar the other tabs use on a phone: the search shrinks, nothing wraps. + const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [field, setField] = useState("name"); const [heardKey, setHeardKey] = useState(null); @@ -83,39 +86,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti return (
-
-
- {t("atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} - - {t(storageFailed ? "atlas.storageFailed" : "atlas.localOnly")} - -
- update((value) => ({ ...value, range: range as AtlasRange }))} - ariaLabel={t("atlas.range")} +
+ + {!isMobile && } + + + + {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} + + + {storageFailed && {t("atlas.storageFailed")}} + {!isMobile && {t("atlas.range")}} + update((value) => ({ ...value, range: range as AtlasRange }))} + ariaLabel={t("atlas.range")} + /> +
- } - > - - {hint &&

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

} - {ready && ( -
- {results.isPending ? ( + {typed.length > 0 && ( + +
+ {hint ? ( +

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

+ ) : results.isPending ? (

{t("common.loading")}

) : results.isError ? (

@@ -134,7 +143,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti return (

  • {label} @@ -157,13 +166,13 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti })} )} - {results.data?.hasMore && ( + {results.data?.hasMore && !hint && (

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

    )} - {full &&

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

    } + {full && !hint &&

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

    }
  • - )} -
    + + )} {saved.nodes.length === 0 ? ( ) : ( diff --git a/src/features/observers/ObserverSidebar.tsx b/src/features/observers/ObserverSidebar.tsx index a7de7e57..2bdd4d72 100644 --- a/src/features/observers/ObserverSidebar.tsx +++ b/src/features/observers/ObserverSidebar.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; +import { InfoTip } from "../../components/InfoTip"; import { formatCount, formatUtc } from "../../lib/formatters"; import { SCROLL_BOTTOM_THRESHOLD_PX } from "../../lib/constants"; import { Segmented } from "../stats/Segmented"; @@ -35,24 +36,30 @@ export function ObserverSidebar({ observers, filtered, isPending, isError, unsup }, [loadNearEnd, observers.length]); const messageClass = "px-3 py-3 text-center font-mono text-[11px] text-text-dim"; const retry = ; + const partial = coverage != null && coverage.status !== "complete"; + // Incomplete coverage is a footnote in the tip; counts missing altogether changes the list, so that stays visible. return (
    -
    -
    {t("tabs.Observers")}
    - onSortChange(v as ObserverDirectorySort)} - options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} /> +
    +
    +
    {t("tabs.Observers")}
    + {windowEnd != null && } +
    +
    + {onRefresh && !unsupported && } + onSortChange(v as ObserverDirectorySort)} + options={[{ value: "traffic", label: t("observerPage.sortActivity") }, { value: "name", label: t("observerPage.sortName") }]} /> +
    - {onRefresh && !unsupported && } - {coverage && coverage.status !== "complete" &&

    - {t(maxObservationCount != null ? "observerPage.countsPartial" - : effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")} + {partial && maxObservationCount == null &&

    + {t(effectiveSort === "name" ? "observerPage.countsUnavailableName" : "observerPage.countsUnavailable")}

    } - {windowEnd != null &&
    -

    {t("observerPage.directoryPeriod")}

    -

    {t("observerPage.countsThrough", { time: formatUtc(windowEnd) })}

    -
    } {unsupported ?

    {t("observerPage.upgradeRequired")}

    : isPending ?
    {t("common.loading")}
    diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 5655e362..7b14d1ad 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -90,6 +90,7 @@ export function StatCard({ markPeak, presence, sublabel, + compact, }: { label: string; value: ReactNode; @@ -98,15 +99,18 @@ export function StatCard({ markPeak?: boolean; presence?: boolean[]; sublabel?: ReactNode; + compact?: boolean; // narrow tiles: value stays text-xl so "-77.2 dBm" never wraps }) { return ( -
    +
    {label} {sublabel && {sublabel}}
    -
    {value}
    - {presence ? : spark ? : sparkSpacer} +
    {value}
    +
    + {presence ? : spark ? : sparkSpacer} +
    ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 84263a15..d9d97e9d 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -958,12 +958,14 @@ }, "atlas": { "addNodes": "Add nodes", + "matches": "Matches", "search": "Find a node", "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", "savedCount_other": "{{count}} / {{limit}} saved", + "savedCountShort": "{{count}}/{{limit}}", "localOnly": "Saved in this browser", "storageFailed": "Browser storage is unavailable; changes last only for this visit.", "limitReached": "All {{limit}} slots are in use. Remove a card to add another.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 38a31523..0b03911a 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -959,6 +959,7 @@ }, "atlas": { "addNodes": "Ajouter des nœuds", + "matches": "Résultats", "search": "Trouver un nœud", "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.", @@ -966,6 +967,7 @@ "savedCount_one": "{{count}} / {{limit}} enregistré", "savedCount_many": "{{count}} / {{limit}} enregistrés", "savedCount_other": "{{count}} / {{limit}} enregistrés", + "savedCountShort": "{{count}}/{{limit}}", "localOnly": "Enregistré dans ce navigateur", "storageFailed": "Le stockage du navigateur est indisponible ; les modifications ne durent que cette visite.", "limitReached": "Les {{limit}} emplacements sont utilisés. Retirez une fiche pour en ajouter une autre.", diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index 1b78c22c..9711af77 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -98,6 +98,16 @@ describe("AtlasCard", () => { expect(actions.parentElement).toHaveClass("flex-wrap"); }); + it("fills its grid row so neighbouring cards stay the same height whatever their titles wrap to", async () => { + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + const tiles = screen.getByText("Observations").closest(".grid")!; + expect(tiles).toHaveClass("flex-1"); + expect(tiles.parentElement).toHaveClass("flex-1"); + expect(tiles.closest(".rounded-lg.border.p-3\\.5")).toHaveClass("flex", "h-full", "flex-col"); + expect(screen.getByText("-106.0 dBm")).toHaveClass("text-xl"); + }); + it("labels an unnamed node by its ID prefix, dimmed, as the Nodes page does", async () => { vi.mocked(api.getNode).mockResolvedValue({ ...node, name: "" }); mount({ pin: { ...pin, name: "" } }); @@ -144,7 +154,8 @@ describe("AtlasCard", () => { expect(summary).toHaveTextContent(/ago$/); expect(screen.getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument(); - expect(summary.parentElement).toHaveStyle({ minHeight: "240px" }); + expect(summary.parentElement).toHaveStyle({ height: "240px" }); + expect(summary.nextElementSibling).toHaveClass("overflow-y-auto"); expect(screen.getAllByRole("button", { name: "Home listener" })).toHaveLength(1); expect(screen.getAllByText("YKF")[0]).toHaveClass("bg-primary/6"); fireEvent.click(screen.getByRole("button", { name: "Home listener" })); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index ff7a855f..6496a9b3 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -65,6 +65,33 @@ describe("My Atlas", () => { expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); }); + 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" }); + 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(); + expect(screen.queryByText("Matches")).not.toBeInTheDocument(); + typeSearch("repeater"); + expect(await screen.findByRole("button", { name: "Renamed repeater is saved" })).toBeInTheDocument(); + expect(screen.getByText("Matches")).toBeInTheDocument(); + }); + + it("keeps the toolbar to one row on a phone: short count chip, picker, no info tip", async () => { + vi.stubGlobal("matchMedia", (query: string) => ({ matches: /max-width/.test(query), media: query, addEventListener: () => {}, removeEventListener: () => {} })); + save(); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + 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(); + expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument(); + expect(within(bar).queryByText(/More information/)).not.toBeInTheDocument(); + }); + it("shows the empty state and queries nothing with no pins", () => { mount(); expect(screen.getByText("No nodes saved")).toBeInTheDocument(); diff --git a/tests/features/observers/ObserverSidebar.test.tsx b/tests/features/observers/ObserverSidebar.test.tsx index a7ebe229..91ce4456 100644 --- a/tests/features/observers/ObserverSidebar.test.tsx +++ b/tests/features/observers/ObserverSidebar.test.tsx @@ -93,6 +93,17 @@ it.each(["partial", "unavailable"] as const)("renders available traffic and zero expect(rows[2].querySelector('[style]')).toHaveStyle({ width: "0%" }); expect(screen.queryByText(/counts unavailable/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Name order/)).not.toBeInTheDocument(); - expect(screen.getByText(/coverage is incomplete/)).toBeInTheDocument(); - expect(screen.getByText(/Available history, up to 7 days/)).toBeInTheDocument(); + expect(screen.getByText(/More information: Counts use available history; coverage is incomplete\. Available history, up to 7 days Counts through 2026-10-0\d \d\d:00 UTC/)).toBeInTheDocument(); + expect(screen.queryByText("Available history, up to 7 days")).not.toBeInTheDocument(); +}); + +it("keeps the history notes in the info tip and the panel title off the phone layout", () => { + render(); + expect(screen.getByText(/^More information: Available history, up to 7 days Counts through/)).toBeInTheDocument(); + expect(screen.queryByText(/coverage is incomplete/)).not.toBeInTheDocument(); + expect(screen.getByText("Observers")).toHaveClass("hidden", "md:block"); + const controls = screen.getByRole("button", { name: "Refresh" }).parentElement!; + expect(controls).toContainElement(screen.getByRole("button", { name: "Name" })); + expect(controls).toHaveClass("ml-auto"); + expect(controls.parentElement).toHaveClass("flex-wrap"); }); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index 831550c2..cf5be95f 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -11,6 +11,15 @@ it("keeps the header on one line unless a caller asks for the right slot to wrap expect(screen.getByRole("button", { name: "Act" }).parentElement).toHaveClass("flex-wrap"); }); +it("keeps a compact tile's value small and its chart at the foot so stretched tiles stay uniform", () => { + const { container } = render(); + const tile = container.firstElementChild!; + expect(tile).toHaveClass("flex", "flex-col"); + expect(screen.getByText("-77.2 dBm")).toHaveClass("text-xl"); + expect(screen.getByText("-77.2 dBm")).not.toHaveClass("sm:text-2xl"); + expect(container.querySelector("svg")!.parentElement).toHaveClass("mt-auto"); +}); + it("marks the peak hour on a sparkline when asked", () => { const { container } = render(); const dot = container.querySelector("[data-peak]")!; From c3d54c5d4423d4d0a45c63e9f2cecc8b7188e49d Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Tue, 13 Oct 2026 13:14:08 -0400 Subject: [PATCH 08/10] Atlas: keep the Heard by toggle one width so flipping never wraps the header --- src/features/atlas/AtlasCard.tsx | 15 +++++++++++++-- tests/features/atlas/AtlasCard.test.tsx | 4 ++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 0e2f58e4..c244c7a6 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -100,16 +100,27 @@ export function AtlasCard({ ); + // 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 = (
    {node && {node.nodeTypeName}} - {node?.stale === false && {t("observerPage.statusRecent")}} - {node?.stale === true && {t("observerPage.statusStale")}} + {node?.stale === false && {t("atlas.recent")}} + {node?.stale === true && {t("atlas.stale")}} ); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index d9d97e9d..58dcca3c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -987,6 +987,8 @@ "lastSeen": "Last seen", "meanHops": "Mean hops", "heardBy": "Heard by", + "recent": "Recent", + "stale": "Stale", "observers_one": "{{count}} observer heard the latest packet", "observers_other": "{{count}} observers heard the latest packet", "noReports": "No observations in this window.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 0b03911a..086a4976 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -989,6 +989,8 @@ "lastSeen": "Vu pour la dernière fois", "meanHops": "Sauts moyens", "heardBy": "Entendu par", + "recent": "Récent", + "stale": "Périmé", "observers_one": "{{count}} observateur a entendu le dernier paquet", "observers_many": "{{count}} observateurs ont entendu le dernier paquet", "observers_other": "{{count}} observateurs ont entendu le dernier paquet", diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index cac643e5..9c100d64 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -56,7 +56,7 @@ describe("AtlasCard", () => { mount(); expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); expect(screen.getByText("REPEATER")).toBeInTheDocument(); - expect(screen.getByText("Recent status")).toBeInTheDocument(); + expect(screen.getByText("Recent")).toBeInTheDocument(); expect(screen.getByText("Observations")).toBeInTheDocument(); expect(screen.getByText("Mean SNR")).toBeInTheDocument(); expect(screen.getByText("0.0 dB")).toBeInTheDocument(); @@ -71,7 +71,7 @@ describe("AtlasCard", () => { it("shows the stale badge and the server's type name even when the type is unknown", async () => { vi.mocked(api.getNode).mockResolvedValue({ ...node, stale: true, nodeType: 9, nodeTypeName: "WEIRD" }); mount(); - expect(await screen.findByText("Status is stale")).toBeInTheDocument(); + expect(await screen.findByText("Stale")).toBeInTheDocument(); expect(screen.getByText("WEIRD")).toBeInTheDocument(); expect(screen.queryByText(/atlas\.roles/)).not.toBeInTheDocument(); }); From b2863816fa957cbd270e2302f522595bfc6a4bbc Mon Sep 17 00:00:00 2001 From: MrAlders0n Date: Wed, 14 Oct 2026 09:06:10 -0400 Subject: [PATCH 10/10] Atlas: observer view, observer pins and observer search --- src/features/atlas/AtlasCard.tsx | 196 +++++++++++++----- src/features/atlas/MyAtlasPage.tsx | 151 ++++++++++---- src/features/atlas/atlas.ts | 59 ++++-- src/features/atlas/observer-sample.ts | 81 ++++++++ src/features/atlas/useAtlas.ts | 11 +- src/features/atlas/useAtlasObserver.ts | 58 ++++++ src/i18n/locales/en.json | 17 ++ src/i18n/locales/fr.json | 17 ++ tests/features/atlas/AtlasCard.test.tsx | 131 ++++++++++-- tests/features/atlas/MyAtlasPage.test.tsx | 82 +++++++- tests/features/atlas/atlas.test.ts | 58 +++++- tests/features/atlas/observer-sample.test.ts | 64 ++++++ .../features/atlas/useAtlasObserver.test.tsx | 77 +++++++ 13 files changed, 858 insertions(+), 144 deletions(-) create mode 100644 src/features/atlas/observer-sample.ts create mode 100644 src/features/atlas/useAtlasObserver.ts create mode 100644 tests/features/atlas/observer-sample.test.ts create mode 100644 tests/features/atlas/useAtlasObserver.test.tsx diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 4336f2d5..e5a776ad 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -1,21 +1,29 @@ import { useEffect, useMemo, useRef, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { isNotFound } from "../../api/client"; +import { queryRetryDelay } from "../../api/rate-limit"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { Badge } from "../../components/Badge"; import { IataChip } from "../../components/IataChip"; import { InfoTip } from "../../components/InfoTip"; import { Timestamp } from "../../components/Timestamp"; -import { formatCount, formatHex } from "../../lib/formatters"; +import { formatBattery, formatCount, formatHex, formatRadio, formatRadioParts, formatUptime } from "../../lib/formatters"; import type { Observation } from "../../types/api"; +import type { NodeSummary } from "../nodes/types"; +import { deriveObserverStatus } from "../observers/observer-status"; import { usePacketDetail } from "../packets/usePacketDetail"; import { Card, StatCard } from "../stats/cards"; import { useChartColors } from "../stats/chartTheme"; +import { Segmented } from "../stats/Segmented"; import { average } from "../stats/signal"; -import { REPORT_LIMIT, type AtlasPin, type AtlasRange } from "./atlas"; -import { useAtlasNode } from "./useAtlas"; +import { REPORT_LIMIT, resolveObserverNode, type AtlasPin, type AtlasRange } from "./atlas"; +import { ATLAS_REFRESH_MS, useAtlasNode } from "./useAtlas"; +import { useAtlasObserver } from "./useAtlasObserver"; import type { CardHandleProps } from "./useCardDrag"; +export type AtlasView = "analytics" | "heard" | "observer"; + export interface AtlasActions { onViewNode: (id: string) => void; onViewObserver: (id: string) => void; @@ -26,22 +34,27 @@ interface AtlasCardProps extends AtlasActions { pin: AtlasPin; range: AtlasRange; active: boolean; - heardOpen: boolean; - onHeardToggle: () => void; + view: AtlasView; + onViewChange: (view: AtlasView) => void; onRemove: () => void; - onResolved: (id: string) => void; + onResolved: (id: string, observerId?: string) => void; + onUpgrade: (node: NodeSummary, observerId: string) => void; handle: Partial; } -// One pinned node, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way. +const VIEW_LABEL: Record = { analytics: "atlas.viewAnalytics", heard: "atlas.viewHeard", observer: "atlas.viewObserver" }; + +// One pinned radio, laid out as the Signal tab's KPI row so Atlas and Analytics read the same way. +// A node that also observes gets a third view; an observer-only pin has just that view. export function AtlasCard({ pin, range, active, - heardOpen, - onHeardToggle, + view, + onViewChange, onRemove, onResolved, + onUpgrade, handle, onViewNode, onViewObserver, @@ -49,12 +62,18 @@ export function AtlasCard({ }: AtlasCardProps) { const { t } = useTranslation(); const c = useChartColors(); - const { query, node, sample, capped } = useAtlasNode(pin, range, active); + const nodePin = pin.kind === "node" ? pin : null; + const { query, node, sample, capped } = useAtlasNode(nodePin, range, active); + const observerId = pin.kind === "observer" ? pin.observerId : (node?.observerId ?? pin.observerId ?? null); + const obs = useAtlasObserver(observerId, range, active, view === "observer"); + const observer = obs.observer.data; + const views: AtlasView[] = pin.kind === "observer" ? ["observer"] : observerId ? ["analytics", "heard", "observer"] : ["analytics", "heard"]; + const latest = sample.reports[0]; - const heard = usePacketDetail(heardOpen && latest ? latest.packetHash : null); + const heard = usePacketDetail(view === "heard" && latest ? latest.packetHash : null); const detail = heard.data; // Never show another node's observers if the packet record resolved to a different origin. - const sameOrigin = !detail?.originPubkey || detail.originPubkey.toLowerCase() === pin.publicKey; + const sameOrigin = !detail?.originPubkey || detail.originPubkey.toLowerCase() === nodePin?.publicKey; const observers = useMemo(() => { if (!detail || !sameOrigin) return []; const latestByObserver = new Map(); @@ -65,63 +84,78 @@ export function AtlasCard({ return [...latestByObserver.values()].sort((a, b) => b.heardAt - a.heardAt); }, [detail, sameOrigin]); - // A pin recovered under a new server ID is saved back so the next poll skips the key lookup. + // A pin recovered under a new server ID, or newly linked to an observer, is saved back. A node + // record without the link (older server) keeps whatever link the pin already has. useEffect(() => { - if (node && node.id !== pin.id) onResolved(node.id); - }, [node, pin.id, onResolved]); + if (!nodePin || !node) return; + const linked = node.observerId ?? nodePin.observerId; + if (node.id !== nodePin.id || linked !== nodePin.observerId) onResolved(node.id, linked); + }, [node, nodePin, onResolved]); - const name = node?.name || pin.name; + // An observer that now advertises becomes a node pin so the card gains the node views. A query, + // not an effect, so the lookup runs once per poll rather than on every parent render. + const upgradeTo = useQuery({ + queryKey: ["atlas-observer-node", pin.kind === "observer" ? (observer?.publicKey ?? null) : null], + queryFn: ({ signal }) => resolveObserverNode(observer!, signal), + enabled: pin.kind === "observer" && active && !!observer?.publicKey, + staleTime: ATLAS_REFRESH_MS, + refetchInterval: ATLAS_REFRESH_MS, + refetchIntervalInBackground: false, + refetchOnWindowFocus: false, + retryDelay: queryRetryDelay, + }); + useEffect(() => { + if (pin.kind === "observer" && upgradeTo.data) onUpgrade(upgradeTo.data, pin.observerId); + }, [pin, upgradeTo.data, onUpgrade]); + + const name = (pin.kind === "node" ? node?.name : observer?.displayName) || pin.name; const hours = sample.counts.length; const activeHours = sample.present.filter(Boolean).length; + const status = observer ? deriveObserverStatus(observer) : null; + // The observer detail carries the radio as separate fields; the summary shape has the compact string. + const radio = observer && (formatRadio(observer.radio) ?? formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr })); // The list view is fixed to the tiles' height and scrolls inside it, so flipping never resizes the card. const body = useRef(null); const [held, setHeld] = useState(); - const toggleHeard = () => { - if (!heardOpen) setHeld(body.current?.offsetHeight); - onHeardToggle(); + const changeView = (next: AtlasView) => { + if (view !== "heard") setHeld(body.current?.offsetHeight); + onViewChange(next); }; const title = ( {node && {node.nodeTypeName}} {node?.stale === false && {t("atlas.recent")}} {node?.stale === true && {t("atlas.stale")}} + {pin.kind === "observer" && observer?.observerType && {observer.observerType}} + {pin.kind === "observer" && status && ( + {t(status === "online" ? "atlas.online" : "atlas.offline")} + )} ); - // Both toggle labels share one grid cell so the button is as wide as the longer one in either - // state; otherwise flipping could push the actions under the title and grow the card. - const labels = [t("atlas.heardBy"), t("tabs.Analytics")]; const actions = (
    - + {views.length > 1 && ( + changeView(next as AtlasView)} + options={views.map((value) => ({ value, label: t(VIEW_LABEL[value]) }))} + /> + )} @@ -140,21 +174,29 @@ export function AtlasCard({
    ); + const retry = (refetch: () => Promise) => ( +

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

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

    {t("common.loading")}

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

    {t("common.loading")}

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

    {t("nodeDetail.notFound")}

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

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

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

    {t("common.loading")}

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

    {t("atlas.observerGone")}

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

    {t("atlas.noReports")}

    @@ -242,6 +284,54 @@ export function AtlasCard({ )}
    )} + {view === "observer" && observerId && (pin.kind === "observer" ? observer : node) && ( +
    +
    + + + + {obs.sample.battery !== null ? ( + + ) : ( + + )} +
    +
    + {status && {t(status === "online" ? "atlas.online" : "atlas.offline")}} + {observer?.lastStatusAt != null && ( + + {t("atlas.lastStatus")} + + )} + {radio && {radio}} + {obs.loading && {t("common.loading")}} + {obs.failed && {t("common.loadFailed")}} + + + +
    +
    + )} ); } diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index f6e152ca..86fe5fda 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { getNodesPage } from "../../api/client"; +import { getNodesPage, getObserver, getObserverDirectoryPage } from "../../api/client"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { Badge } from "../../components/Badge"; import { EmptyState } from "../../components/EmptyState"; @@ -12,10 +12,13 @@ import { useIsMobile } from "../../hooks/useMediaQuery"; import { formatHex } from "../../lib/formatters"; import { nodeSearchParams } from "../nodes/node-search"; import type { NodeSummary } from "../nodes/types"; +import { observerName } from "../observers/observer-filter"; +import { deriveObserverStatus } from "../observers/observer-status"; +import type { ObserverDirectoryItem } from "../observers/types"; import { Card } from "../stats/cards"; import { Segmented } from "../stats/Segmented"; -import { AtlasCard, type AtlasActions } from "./AtlasCard"; -import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas"; +import { AtlasCard, type AtlasActions, type AtlasView } from "./AtlasCard"; +import { ATLAS_LIMIT, parseAtlas, pinKey, resolveObserverNode, type AtlasPin, type AtlasRange } from "./atlas"; import { useCardDrag } from "./useCardDrag"; import { useSavedAtlas } from "./useSavedAtlas"; @@ -23,7 +26,11 @@ const RANGES: AtlasRange[] = ["24h", "3d"]; const SEARCH_LIMIT = 8; const SEARCH_MIN = 2; -// Global by design: pins are node identities, so the header region picker does not apply here. +type Matches = + | { kind: "node"; items: NodeSummary[]; hasMore: boolean } + | { kind: "observer"; items: ObserverDirectoryItem[]; hasMore: boolean }; + +// Global by design: pins are radio identities, so the header region picker does not apply here. export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) { const { t } = useTranslation(); const { saved, update, storageFailed } = useSavedAtlas(); @@ -31,7 +38,11 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [field, setField] = useState("name"); - const [heardKey, setHeardKey] = useState(null); + // One card at a time is away from its default view. + const [open, setOpen] = useState<{ key: string; view: AtlasView } | null>(null); + const defaultView = (pin: AtlasPin): AtlasView => (pin.kind === "observer" ? "observer" : "analytics"); + const viewOf = (pin: AtlasPin) => (open?.key === pinKey(pin) ? open.view : defaultView(pin)); + const setView = (pin: AtlasPin, view: AtlasView) => setOpen(view === defaultView(pin) ? null : { key: pinKey(pin), view }); // Switching the field changes what the box means, so stale text must not carry over. const changeField = (value: string) => { setField(value); @@ -39,6 +50,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti }; // Key on the derived params, as the Nodes table does, so a name is never sent as a hex prefix. + const observerSearch = field === "observer"; const params = nodeSearchParams(field, search); const typed = search.trim(); const hint = typed.length === 0 ? null @@ -46,43 +58,83 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti : field === "pubkey" && params.pubkeyPrefix === undefined ? "atlas.searchNotHex" : null; const ready = typed.length > 0 && hint === null; - const results = useQuery({ - queryKey: ["atlas-search", params.name, params.pubkeyPrefix], - queryFn: ({ signal }) => getNodesPage(undefined, { ...params, limit: SEARCH_LIMIT }, signal), + const results = useQuery({ + queryKey: observerSearch ? ["atlas-search-observers", typed] : ["atlas-search", params.name, params.pubkeyPrefix], + queryFn: ({ signal }) => observerSearch + ? getObserverDirectoryPage({ name: typed, limit: SEARCH_LIMIT }, signal).then((page) => ({ kind: "observer", items: page.items, hasMore: page.hasMore })) + : getNodesPage(undefined, { ...params, limit: SEARCH_LIMIT }, signal).then((page) => ({ kind: "node", items: page.items, hasMore: page.hasMore })), enabled: active && ready, staleTime: 30_000, }); - const full = saved.nodes.length >= ATLAS_LIMIT; + const full = saved.pins.length >= ATLAS_LIMIT; const fields: SearchFieldOption[] = [ { value: "name", label: t("nodeFilters.fieldName") }, { value: "pubkey", label: t("nodeFilters.fieldPubkey") }, + { value: "observer", label: t("atlas.fieldObserver") }, ]; const rangeOptions = RANGES.map((value) => ({ value, label: t(`atlas.ranges.${value}`) })); // Re-parse so an add goes through the same validation, dedupe and cap as a restore. + const append = (pin: AtlasPin) => + update((value) => parseAtlas(JSON.stringify({ ...value, pins: [...value.pins, pin] }))); const add = (node: NodeSummary) => + append({ kind: "node", id: node.id, publicKey: node.publicKey, name: node.name || "", observerId: node.observerId }); + // An observer whose radio advertises is saved as that node; if the node is pinned already, it + // just gains the link, so the result reads Saved instead of silently doing nothing. + const addObserver = useMutation({ + mutationFn: async (row: ObserverDirectoryItem) => { + const observer = await getObserver(row.id); + const match = await resolveObserverNode(observer); + const name = row.displayName || ""; + if (!match) return append({ kind: "observer", observerId: row.id, name }); + const publicKey = match.publicKey.toLowerCase(); + update((value) => value.pins.some((pin) => pin.kind === "node" && pin.publicKey === publicKey) + ? { ...value, pins: value.pins.map((pin) => (pin.kind === "node" && pin.publicKey === publicKey ? { ...pin, observerId: row.id } : pin)) } + : parseAtlas(JSON.stringify({ ...value, pins: [...value.pins, { kind: "node", id: match.id, publicKey, name: match.name || name, observerId: row.id }] }))); + }, + }); + const remove = (pin: AtlasPin) => + update((value) => ({ ...value, pins: value.pins.filter((other) => pinKey(other) !== pinKey(pin)) })); + const relink = (pin: AtlasPin, id: string, observerId?: string) => + update((value) => ({ ...value, pins: value.pins.map((other) => (pinKey(other) === pinKey(pin) && other.kind === "node" ? { ...other, id, observerId } : other)) })); + // An observer pin whose key now resolves to a node takes that node's place in its own slot. + const upgrade = (pin: AtlasPin, match: NodeSummary, observerId: string) => update((value) => { - const pin = { id: node.id, publicKey: node.publicKey, name: node.name || "" }; - return parseAtlas(JSON.stringify({ ...value, nodes: [...value.nodes, pin] })); + const publicKey = match.publicKey.toLowerCase(); + if (value.pins.some((other) => other.kind === "node" && other.publicKey === publicKey)) { + return { ...value, pins: value.pins.filter((other) => pinKey(other) !== pinKey(pin)) }; + } + return { ...value, pins: value.pins.map((other) => (pinKey(other) === pinKey(pin) + ? { kind: "node", id: match.id, publicKey, name: match.name || pin.name, observerId } + : other)) }; }); - const remove = (publicKey: string) => - update((value) => ({ ...value, nodes: value.nodes.filter((node) => node.publicKey !== publicKey) })); - const relink = (publicKey: string, id: string) => - update((value) => ({ ...value, nodes: value.nodes.map((node) => (node.publicKey === publicKey ? { ...node, id } : node)) })); const reorder = (from: number, to: number) => update((value) => { - const nodes = [...value.nodes]; - const [moved] = nodes.splice(from, 1); - if (!moved || to < 0 || to >= value.nodes.length) return value; - nodes.splice(to, 0, moved); - return { ...value, nodes }; + const pins = [...value.pins]; + const [moved] = pins.splice(from, 1); + if (!moved || to < 0 || to >= value.pins.length) return value; + pins.splice(to, 0, moved); + return { ...value, pins }; }); - const drag = useCardDrag(saved.nodes.length, reorder); + const drag = useCardDrag(saved.pins.length, reorder); const cardProps = (index: number) => { const props = drag.cardProps(index); return { ...props, className: `min-w-0 ${props.className ?? ""}` }; }; + const pinnedObserver = (id: string) => saved.pins.some((pin) => pin.observerId === id); + const addButton = (pinned: boolean, label: string, onClick: () => void, busy = false) => ( + + ); + const rowClass = "flex items-center justify-between gap-2 border-t border-border-subtle py-1.5 first:border-t-0"; return (
    @@ -103,7 +155,7 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti - {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} + {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.pins.length, limit: ATLAS_LIMIT })} {storageFailed && {t("atlas.storageFailed")}} @@ -135,37 +187,45 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti

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

    {t("common.noMatches")}

    - ) : ( + ) : results.data.kind === "node" ? (
      {results.data.items.map((node) => { - const pinned = saved.nodes.some((pin) => pin.publicKey === node.publicKey.toLowerCase()); + const pinned = saved.pins.some((pin) => pin.kind === "node" && pin.publicKey === node.publicKey.toLowerCase()); const label = node.name ?? formatHex(node.id); return ( -
    • +
    • {label} {node.nodeTypeName} + {node.isObserver && {t("atlas.observerBadge")}} {formatHex(node.publicKey)} - + {addButton(pinned, label, () => add(node))} +
    • + ); + })} +
    + ) : ( +
      + {results.data.items.map((row) => { + const label = observerName(row); + const online = deriveObserverStatus(row) === "online"; + return ( +
    • + + + {label} + {row.observerType && {row.observerType}} + + {addButton(pinnedObserver(row.id), label, () => addObserver.mutate(row), addObserver.isPending)}
    • ); })}
    )} + {addObserver.isError &&

    {t("common.loadFailed")}

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

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

    )} @@ -173,20 +233,21 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti
    )} - {saved.nodes.length === 0 ? ( + {saved.pins.length === 0 ? ( ) : (
    - {saved.nodes.map((pin, index) => ( -
    + {saved.pins.map((pin, index) => ( +
    setHeardKey((key) => (key === pin.publicKey ? null : pin.publicKey))} - onRemove={() => remove(pin.publicKey)} - onResolved={(id) => relink(pin.publicKey, id)} + view={viewOf(pin)} + onViewChange={(view) => setView(pin, view)} + onRemove={() => remove(pin)} + onResolved={(id, observerId) => relink(pin, id, observerId)} + onUpgrade={(match, observerId) => upgrade(pin, match, observerId)} handle={drag.handleProps(index)} {...actions} /> diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index b6bc357b..0ddf248f 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -1,34 +1,63 @@ import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from "../../api/client"; -import type { Node, NodeObservation } from "../nodes/types"; +import type { Node, NodeObservation, NodeSummary } from "../nodes/types"; +import type { Observer } from "../observers/types"; export const ATLAS_KEY = "beacon-my-atlas-v1"; export const ATLAS_LIMIT = 12; export const REPORT_LIMIT = 200; export type AtlasRange = "24h" | "3d"; -export interface AtlasPin { id: string; publicKey: string; name: string } -export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] } +export interface NodePin { kind: "node"; id: string; publicKey: string; name: string; observerId?: string } +export interface ObserverPin { kind: "observer"; observerId: string; name: string } +export type AtlasPin = NodePin | ObserverPin; +export interface SavedAtlas { version: 2; range: AtlasRange; pins: AtlasPin[] } +const UUID = /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i; +const KEY = /^[\da-f]{64}$/i; +const isUuid = (value: unknown): value is string => typeof value === "string" && UUID.test(value); + +export const pinKey = (pin: AtlasPin) => (pin.kind === "node" ? pin.publicKey : pin.observerId); + +// Version 1 stored `nodes`; version 2 stores `pins` with a kind. Both load, anything else is empty. export function parseAtlas(raw: string | null): SavedAtlas { - const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] }; + const empty: SavedAtlas = { version: 2, range: "24h", pins: [] }; try { if (!raw || raw.length > 32_000) return empty; const value = JSON.parse(raw); - if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty; - const nodes: AtlasPin[] = []; - for (const pin of value.nodes) { - if (typeof pin?.id !== "string" || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id) || - typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue; - const publicKey = pin.publicKey.toLowerCase(); - if (nodes.some(node => node.publicKey === publicKey)) continue; - nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" }); - if (nodes.length === ATLAS_LIMIT) break; + const list = value?.version === 1 ? value.nodes : value?.version === 2 ? value.pins : null; + if (!Array.isArray(list)) return empty; + const pins: AtlasPin[] = []; + for (const entry of list) { + const pin = toPin(entry, value.version === 1); + if (!pin || pins.some((other) => pinKey(other) === pinKey(pin))) continue; + pins.push(pin); + if (pins.length === ATLAS_LIMIT) break; } - return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes }; + return { version: 2, range: value.range === "3d" ? "3d" : "24h", pins }; } catch { return empty; } } +function toPin(raw: unknown, legacy: boolean): AtlasPin | null { + if (!raw || typeof raw !== "object") return null; + const value = raw as Record; + const name = typeof value.name === "string" ? value.name.slice(0, 160) : ""; + const kind = legacy ? "node" : value.kind; + if (kind === "observer") return isUuid(value.observerId) ? { kind, observerId: value.observerId, name } : null; + if (kind !== "node" || !isUuid(value.id) || typeof value.publicKey !== "string" || !KEY.test(value.publicKey)) return null; + const pin: NodePin = { kind, id: value.id, publicKey: value.publicKey.toLowerCase(), name }; + if (isUuid(value.observerId)) pin.observerId = value.observerId; + return pin; +} + +// The same radio can be a node and an observer; when it advertises, the node is the pin. +export async function resolveObserverNode(observer: Observer, signal?: AbortSignal): Promise { + const key = observer.publicKey?.toLowerCase(); + if (!key) return null; + const page = await getNodesPage(undefined, { pubkeyPrefix: key, limit: 2 }, signal); + return page.items.find((row) => row.publicKey.toLowerCase() === key) ?? null; +} + // Resolve by full identity after a server database reset; never adopt a different node at an old ID. -export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) { +export async function loadAtlasNode(pin: NodePin, signal?: AbortSignal) { let node: Node | undefined; try { node = await getNode(pin.id, signal); diff --git a/src/features/atlas/observer-sample.ts b/src/features/atlas/observer-sample.ts new file mode 100644 index 00000000..5ce65c7a --- /dev/null +++ b/src/features/atlas/observer-sample.ts @@ -0,0 +1,81 @@ +import type { Observer } from "../observers/types"; +import { hasTelemetry } from "../stats/transforms"; +import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../stats/types"; +import type { AtlasRange } from "./atlas"; + +// Go duration strings the observer endpoints expect, per Atlas window; one bucket per hour for both. +export const OBSERVER_RANGE: Record = { "24h": "24h", "3d": "72h" }; +export const OBSERVER_INTERVAL = "1h"; +const RANGE_HOURS: Record = { "24h": 24, "3d": 72 }; +const HOUR = 3_600_000; + +export interface ObserverSample { + heard: number | null; + counts: (number | null)[]; + snrHourly: (number | null)[]; + snr: number | null; + noiseHourly: (number | null)[]; + noise: number | null; + batteryHourly: (number | null)[]; + battery: number | null; + uptime: number | null; +} + +export function summarizeObserver( + activity: ObserverActivity | undefined, + telemetry: ObserverTelemetry | undefined, + observer: Observer | undefined, + range: AtlasRange, + now: number, +): ObserverSample { + const hours = RANGE_HOURS[range]; + // Slots count back from the tick, newest last; a bucket newer than the tick lands in the newest slot. + const age = (t: number) => Math.max(0, Math.floor((now - t) / HOUR)); + const bin = (t: number) => hours - 1 - age(t); + const inWindow = (t: number) => Number.isFinite(t) && age(t) < hours; + + const counts = Array(hours).fill(0); + const snrHourly = Array(hours).fill(null); + let heard: number | null = null; + let weighted = 0; + let weight = 0; + for (const p of activity?.points ?? []) { + if (!inWindow(p.t)) continue; + const i = bin(p.t); + if (p.observations === null) { + counts[i] = null; + continue; + } + counts[i] = (counts[i] ?? 0) + p.observations; + heard = (heard ?? 0) + p.observations; + if (p.snrAvg !== null && p.observations > 0) { + snrHourly[i] = p.snrAvg; + weighted += p.snrAvg * p.observations; + weight += p.observations; + } + } + + // A stray all-zero status row is "no reading", not a 0 dB floor or an empty battery. + const live = (telemetry?.points ?? []).filter((p) => inWindow(p.t) && hasTelemetry([p])).sort((a, b) => a.t - b.t); + const series = (key: keyof TelemetryPoint) => { + const out = Array(hours).fill(null); + for (const p of live) if (p[key] !== null) out[bin(p.t)] = p[key]; + return out; + }; + const latest = (key: keyof TelemetryPoint) => [...live].reverse().find((p) => p[key] !== null)?.[key]; + // Mains-powered observers report 0 mV; a battery never reads zero, so that is "no battery". + const volts = (mv: number | null | undefined) => (mv ? mv / 1000 : null); + const uptime = observer?.uptimeSeconds ?? latest("uptimeSeconds"); + + return { + heard, + counts, + snrHourly, + snr: weight ? weighted / weight : null, + noiseHourly: series("noiseFloorDb"), + noise: latest("noiseFloorDb") ?? null, + batteryHourly: series("batteryMv").map(volts), + battery: volts(latest("batteryMv")), + uptime: uptime ?? null, + }; +} diff --git a/src/features/atlas/useAtlas.ts b/src/features/atlas/useAtlas.ts index 5dfc31cd..8601169f 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 00000000..3664f517 --- /dev/null +++ b/src/features/atlas/useAtlasObserver.ts @@ -0,0 +1,58 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { getObserver, getObserverActivity, getObserverTelemetry, isNotFound } from "../../api/client"; +import { queryRetryDelay } from "../../api/rate-limit"; +import { useRateLimit } from "../../hooks/useRateLimit"; +import { useTick } from "../../hooks/useTick"; +import type { AtlasRange } from "./atlas"; +import { OBSERVER_INTERVAL, OBSERVER_RANGE, summarizeObserver } from "./observer-sample"; +import { ATLAS_REFRESH_MS } from "./useAtlas"; + +// A 404 means this server has no such endpoint; nothing to poll for. +const pollUnlessMissing = (error: unknown) => (isNotFound(error) ? false : ATLAS_REFRESH_MS); + +// The observer record loads whenever the card is active; the series only while the Observer view is open. +export function useAtlasObserver(observerId: string | null, range: AtlasRange, active: boolean, open: boolean) { + const now = useTick(ATLAS_REFRESH_MS); + const limitedUntil = useRateLimit(); + const throttled = limitedUntil !== null && now < limitedUntil; + const base = { + staleTime: ATLAS_REFRESH_MS, + refetchOnWindowFocus: false, + refetchIntervalInBackground: false, + retryDelay: queryRetryDelay, + }; + const observer = useQuery({ + ...base, + queryKey: ["atlas-observer", observerId], + queryFn: () => getObserver(observerId!), + enabled: active && !throttled && observerId !== null, + refetchInterval: ATLAS_REFRESH_MS, + }); + const series = active && !throttled && open && observerId !== null; + const activity = useQuery({ + ...base, + queryKey: ["atlas-observer-activity", observerId, range], + queryFn: () => getObserverActivity(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL, undefined), + enabled: series, + refetchInterval: (query) => pollUnlessMissing(query.state.error), + }); + const telemetry = useQuery({ + ...base, + queryKey: ["atlas-observer-telemetry", observerId, range], + queryFn: () => getObserverTelemetry(observerId!, OBSERVER_RANGE[range], OBSERVER_INTERVAL), + enabled: series, + refetchInterval: (query) => pollUnlessMissing(query.state.error), + }); + const sample = useMemo( + () => summarizeObserver(activity.data, telemetry.data, observer.data, range, now), + [activity.data, telemetry.data, observer.data, range, now], + ); + return { + observer, + sample, + loading: open && (activity.isPending || telemetry.isPending), + unsupported: isNotFound(activity.error) || isNotFound(telemetry.error), + failed: (activity.isError && !isNotFound(activity.error)) || (telemetry.isError && !isNotFound(telemetry.error)), + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 58dcca3c..f6b447e4 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -994,6 +994,23 @@ "noReports": "No observations in this window.", "heardUnavailable": "Packet details are unavailable or expired.", "openPacket": "Open packet", + "fieldObserver": "Observer", + "observerBadge": "Observer", + "viewAnalytics": "Analytics", + "viewHeard": "Heard by", + "viewObserver": "Observer", + "view": "Card view", + "packetsHeard": "Packets heard", + "meanSnrHeard": "Mean SNR heard", + "noiseFloor": "Noise floor", + "battery": "Battery", + "uptime": "Uptime", + "online": "Online", + "offline": "Offline", + "lastStatus": "Last status", + "observerHelp": "What this observer received and reported, over the selected window. Status rows with no readings are ignored.", + "observerUnsupported": "This server does not provide observer activity or telemetry.", + "observerGone": "This observer is no longer known to the server.", "dragToReorder": "Drag to reorder", "remove": "Remove" } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 086a4976..9f303cf3 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -997,6 +997,23 @@ "noReports": "Aucune observation sur cette période.", "heardUnavailable": "Détails du paquet indisponibles ou expirés.", "openPacket": "Ouvrir le paquet", + "fieldObserver": "Observateur", + "observerBadge": "Observateur", + "viewAnalytics": "Analyses", + "viewHeard": "Entendu par", + "viewObserver": "Observateur", + "view": "Vue de la carte", + "packetsHeard": "Paquets reçus", + "meanSnrHeard": "SNR moyen reçu", + "noiseFloor": "Plancher de bruit", + "battery": "Batterie", + "uptime": "Disponibilité", + "online": "En ligne", + "offline": "Hors ligne", + "lastStatus": "Dernier état", + "observerHelp": "Ce que cet observateur a reçu et signalé sur la période choisie. Les états sans mesures sont ignorés.", + "observerUnsupported": "Ce serveur ne fournit pas l’activité ni la télémétrie des observateurs.", + "observerGone": "Cet observateur n’est plus connu du serveur.", "dragToReorder": "Glisser pour réordonner", "remove": "Retirer" } diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx index 9c100d64..3dbed82b 100644 --- a/tests/features/atlas/AtlasCard.test.tsx +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AtlasCard } from "../../../src/features/atlas/AtlasCard"; import * as api from "../../../src/api/client"; import { ApiError } from "../../../src/api/client"; import { formatHex } from "../../../src/lib/formatters"; import type { Node } from "../../../src/features/nodes/types"; +import type { Observer } from "../../../src/features/observers/types"; import type { PacketDetail } from "../../../src/types/api"; vi.mock("../../../src/api/client", async (importOriginal) => ({ @@ -14,9 +15,12 @@ vi.mock("../../../src/api/client", async (importOriginal) => ({ getNodeObservations: vi.fn(), getNodesPage: vi.fn(), getPacketDetail: vi.fn(), + getObserver: vi.fn(), + getObserverActivity: vi.fn(), + getObserverTelemetry: vi.fn(), })); -const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; +const pin = { kind: "node" as const, id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node; const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 }; const detail = { packetHash: report.packetHash, originPubkey: pin.publicKey, header: { payloadType: 4 }, observations: [ @@ -24,20 +28,25 @@ const detail = { packetHash: report.packetHash, originPubkey: pin.publicKey, hea { observerId: "observer-a", observerName: "Home listener", id: 2, heardAt: 2, iata: "YKF" }, { observerId: "observer-b", observerName: "Hill listener", id: 3, heardAt: 3, iata: "YKF" }, ] } as unknown as PacketDetail; +const observerId = "33333333-3333-4333-8333-333333333333"; +const observer = { id: observerId, status: "online", lastStatusAt: Date.now() - 60_000, iata: "YKF", observerType: "repeater", radioFreqMhz: 915, radioSf: 11, radioBwKhz: 250, publicKey: pin.publicKey, firstSeen: 1, lastSeen: 2, observationCount: 5, brokers: [], uptimeSeconds: 90_000 } as Observer; +const activity = { range: "24h", interval: "1h", radio: null, payloadTypes: [], points: [{ t: Date.now() - 3_600_000, observations: 12, snrAvg: 6.5, snrMin: null, rssiAvg: null, airtimeMs: null }] }; +const telemetry = { range: "24h", interval: "1h", points: [{ t: Date.now() - 3_600_000, batteryMv: 4120, noiseFloorDb: -117, airtimeTxSecs: null, airtimeRxSecs: null, uptimeSeconds: null, queueLength: null, receiveErrors: null }] }; const handlers = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn(), - onHeardToggle: vi.fn(), + onViewChange: vi.fn(), onRemove: vi.fn(), onResolved: vi.fn(), + onUpgrade: vi.fn(), }; let client: QueryClient; function mount(overrides: Partial[0]> = {}) { return render( - + , ); } @@ -81,7 +90,7 @@ describe("AtlasCard", () => { vi.mocked(api.getNode).mockResolvedValue({ ...node, id: recovered }); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - expect(handlers.onResolved).toHaveBeenCalledWith(recovered); + expect(handlers.onResolved).toHaveBeenCalledWith(recovered, undefined); }); it("leaves the pin alone when the server ID still matches", async () => { @@ -141,22 +150,18 @@ describe("AtlasCard", () => { expect(api.getPacketDetail).not.toHaveBeenCalled(); expect(screen.queryByRole("button", { name: /Move/ })).not.toBeInTheDocument(); expect(screen.getByRole("button", { name: "Drag to reorder" })).toBeInTheDocument(); - const toggle = screen.getByRole("button", { name: "Heard by" }); - expect(toggle).toHaveAttribute("aria-pressed", "false"); - // Both labels stay in the button, stacked, so flipping never changes its width and wraps the header. - expect(toggle.querySelector('[aria-hidden="true"]')).toHaveTextContent("Analytics"); - expect(toggle.querySelector('[aria-hidden="true"]')).toHaveClass("invisible", "col-start-1", "row-start-1"); - fireEvent.click(toggle); - expect(handlers.onHeardToggle).toHaveBeenCalled(); + const picker = screen.getByRole("group", { name: "Card view" }); + expect(within(picker).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); + fireEvent.click(within(picker).getByRole("button", { name: "Heard by" })); + expect(handlers.onViewChange).toHaveBeenCalledWith("heard"); view.rerender( - + , ); const summary = await screen.findByText(/2 observers heard the latest packet/); expect(summary).toHaveTextContent(/ago$/); - expect(screen.getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("button", { name: "Analytics" }).querySelector('[aria-hidden="true"]')).toHaveTextContent("Heard by"); + expect(screen.getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "true"); expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument(); expect(summary.parentElement).toHaveStyle({ height: "240px" }); expect(summary.nextElementSibling).toHaveClass("overflow-y-auto"); @@ -171,7 +176,7 @@ describe("AtlasCard", () => { it("hides observer links for a different origin identity and explains expired packets", async () => { vi.mocked(api.getPacketDetail).mockResolvedValue({ ...detail, originPubkey: "cd".repeat(32) }); - mount({ heardOpen: true }); + mount({ view: "heard" }); expect(await screen.findByText("Packet details are unavailable or expired.")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Open packet" })).not.toBeInTheDocument(); }); @@ -185,4 +190,98 @@ describe("AtlasCard", () => { expect(handlers.onRemove).toHaveBeenCalled(); expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument(); }); + + it("offers the Observer view only when the node is also an observer, and tells the page the link", async () => { + const first = mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + const picker = screen.getByRole("group", { name: "Card view" }); + expect(within(picker).getAllByRole("button").map((b) => b.textContent)).toEqual(["Analytics", "Heard by"]); + expect(handlers.onResolved).not.toHaveBeenCalled(); + first.unmount(); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + vi.mocked(api.getNode).mockResolvedValue({ ...node, isObserver: true, observerId }); + vi.mocked(api.getObserver).mockResolvedValue(observer); + mount({ pin: { ...pin, id: "22222222-2222-4222-8222-222222222222" } }); + await waitFor(() => expect(handlers.onResolved).toHaveBeenCalledWith(node.id, observerId)); + expect(within(screen.getByRole("group", { name: "Card view" })).getAllByRole("button")).toHaveLength(3); + }); + + it("renders the Observer view tiles from activity and telemetry in the same frame", async () => { + vi.mocked(api.getNode).mockResolvedValue({ ...node, isObserver: true, observerId }); + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getObserverActivity).mockResolvedValue(activity as never); + vi.mocked(api.getObserverTelemetry).mockResolvedValue(telemetry as never); + mount({ view: "observer" }); + expect(await screen.findByText("Packets heard")).toBeInTheDocument(); + await screen.findByText("12"); + expect(screen.getByText("6.5 dB")).toBeInTheDocument(); + expect(screen.getByText("-117 dB")).toBeInTheDocument(); + expect(screen.getByText("4.12V")).toBeInTheDocument(); + expect(screen.getByText("Online")).toBeInTheDocument(); + expect(screen.getByText("915 MHz · SF11 · 250 kHz").closest(".flex-wrap")).toBeInTheDocument(); + expect(screen.getByText("Packets heard").closest(".grid")).toHaveClass("flex-1", "grid-cols-2"); + expect(screen.queryByText("Mean SNR")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Observer" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("shows dashes and explains when the server lacks the observer endpoints", async () => { + vi.mocked(api.getNode).mockResolvedValue({ ...node, isObserver: true, observerId }); + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getObserverActivity).mockRejectedValue(new ApiError(404, "not_found", "no")); + vi.mocked(api.getObserverTelemetry).mockRejectedValue(new ApiError(404, "not_found", "no")); + mount({ view: "observer" }); + expect(await screen.findByText(/More information:.*does not provide observer activity/)).toBeInTheDocument(); + expect(screen.getAllByText("—")).toHaveLength(3); + expect(screen.getByText("1d 1h 0m")).toBeInTheDocument(); + }); + + it("renders an observer-only pin with its status badge, a dashboard link and no picker", async () => { + vi.mocked(api.getObserver).mockResolvedValue({ ...observer, publicKey: "" }); + vi.mocked(api.getObserverActivity).mockResolvedValue(activity as never); + vi.mocked(api.getObserverTelemetry).mockResolvedValue(telemetry as never); + mount({ pin: { kind: "observer", observerId, name: "Hill listener" }, view: "observer" }); + expect(await screen.findByText("repeater")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Hill listener" })); + expect(handlers.onViewObserver).toHaveBeenCalledWith(observerId); + expect(screen.getAllByText("Online")[0]).toHaveClass("bg-green/8"); + expect(screen.queryByRole("group", { name: "Card view" })).not.toBeInTheDocument(); + expect(await screen.findByText("12")).toBeInTheDocument(); + expect(api.getNode).not.toHaveBeenCalled(); + }); + + it("looks an observer pin's node up once per poll, not on every parent render", async () => { + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null }); + const observerPin = { kind: "observer" as const, observerId, name: "Hill listener" }; + const view = mount({ pin: observerPin, view: "observer" }); + await waitFor(() => expect(api.getNodesPage).toHaveBeenCalledTimes(1)); + for (let i = 0; i < 3; i++) { + view.rerender( + + + , + ); + } + await screen.findByText("repeater"); + expect(api.getNodesPage).toHaveBeenCalledTimes(1); + expect(handlers.onUpgrade).not.toHaveBeenCalled(); + }); + + it("says it is loading the observer series and keeps a saved link a node record does not carry", async () => { + vi.mocked(api.getNode).mockResolvedValue({ ...node, id: "22222222-2222-4222-8222-222222222222" }); + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getObserverActivity).mockReturnValue(new Promise(() => {})); + vi.mocked(api.getObserverTelemetry).mockReturnValue(new Promise(() => {})); + mount({ pin: { ...pin, observerId }, view: "observer" }); + expect(await screen.findByText("Packets heard")).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent("Loading"); + await waitFor(() => expect(handlers.onResolved).toHaveBeenCalledWith("22222222-2222-4222-8222-222222222222", observerId)); + }); + + it("upgrades an observer-only pin to a node pin when its key now resolves to a node", async () => { + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [node], hasMore: false, nextCursor: null }); + mount({ pin: { kind: "observer", observerId, name: "Hill listener" }, view: "observer" }); + await waitFor(() => expect(handlers.onUpgrade).toHaveBeenCalledWith(expect.objectContaining({ id: node.id }), observerId)); + }); }); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 6496a9b3..189378b9 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -16,6 +16,8 @@ vi.mock("../../../src/api/client", async (importOriginal) => ({ getNode: vi.fn(), getNodeObservations: vi.fn(), getPacketDetail: vi.fn(), + getObserverDirectoryPage: vi.fn(), + getObserver: vi.fn(), })); const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Old name" }; @@ -23,6 +25,13 @@ const second = { ...pin, id: "22222222-2222-4222-8222-222222222222", publicKey: const node = { ...pin, name: "Renamed repeater", nodeType: 2, nodeTypeName: "REPEATER", knownNeighborCount: 4, stale: false, lastSeen: Date.now(), iatas: [], lat: null, lng: null } as Node; const report = { id: 7, packetHash: "ef".repeat(32), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: Date.now() - 1000, snr: 0, rssi: -106, hopCount: 2 }; const actions = { onViewNode: vi.fn(), onViewObserver: vi.fn(), onAnalyzePacket: vi.fn() }; +const observerId = "33333333-3333-4333-8333-333333333333"; +const listener = { id: observerId, displayName: "Hill listener", observerType: "companion", iata: "YKF", status: "online", observationCount: 7 }; +const directory = (items: unknown[]) => ({ items, hasMore: false, generatedAt: 0, windowStart: 0, windowEnd: 0, sort: "name", effectiveSort: "name", coverage: { status: "complete", expectedHours: 24, completeHours: 24, missingHours: 0, partialHours: 0 }, maxObservationCount: 7, observerTypes: [] }) as never; +const pickField = async (label: string) => { + fireEvent.click(screen.getByRole("button", { name: /Name|Public Key|Observer/ })); + fireEvent.click(await screen.findByRole("button", { name: label })); +}; const clients: QueryClient[] = []; function mount(active = true) { @@ -31,7 +40,7 @@ function mount(active = true) { return render(); } function save(nodes = [pin], range = "24h") { - localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes, range })); + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, pins: nodes.map((n) => ({ kind: "node", ...n })), range })); } const typeSearch = (value: string) => fireEvent.change(screen.getByRole("textbox", { name: "Find a node" }), { target: { value } }); const card = (name: string) => screen.findByRole("button", { name }).then((button) => button.closest("[data-atlas-card]") as HTMLElement); @@ -65,6 +74,12 @@ describe("My Atlas", () => { expect(api.getNodeObservations).toHaveBeenCalledWith(pin.id, { limit: 200 }, expect.any(AbortSignal)); }); + it("still loads a version 1 collection", async () => { + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes: [pin], range: "24h" })); + mount(); + expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); + }); + it("keeps the search in a toolbar with the saved count chip beside the range picker, and lists matches only while typing", async () => { save(); mount(); @@ -107,7 +122,7 @@ describe("My Atlas", () => { expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); expect(screen.getByText("Showing the first 8 matches; refine the search for more.")).toBeInTheDocument(); - expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toHaveLength(1); expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { name: "repeater", limit: 8 }, expect.any(AbortSignal)); view.unmount(); mount(); @@ -165,6 +180,63 @@ describe("My Atlas", () => { expect(screen.getByText(/All 12 slots are in use/)).toBeInTheDocument(); }); + it("searches observers by name and saves a node pin when the observer's key belongs to a node", async () => { + vi.mocked(api.getObserverDirectoryPage).mockResolvedValue(directory([listener])); + vi.mocked(api.getObserver).mockResolvedValue({ ...listener, publicKey: pin.publicKey } as never); + mount(); + await pickField("Observer"); + typeSearch("hill"); + expect(await screen.findByText("Hill listener")).toBeInTheDocument(); + expect(api.getObserverDirectoryPage).toHaveBeenCalledWith({ name: "hill", limit: 8 }, expect.anything()); + fireEvent.click(screen.getByRole("button", { name: "Add Hill listener" })); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toEqual([{ kind: "node", id: pin.id, publicKey: pin.publicKey, name: "Renamed repeater", observerId }])); + expect(screen.getByRole("button", { name: "Hill listener is saved" })).toBeDisabled(); + }); + + it("saves an observer-only pin when no node has the observer's key, and marks it saved by id", async () => { + vi.mocked(api.getObserverDirectoryPage).mockResolvedValue(directory([listener])); + vi.mocked(api.getObserver).mockResolvedValue({ ...listener, publicKey: "" } as never); + mount(); + await pickField("Observer"); + typeSearch("hill"); + fireEvent.click(await screen.findByRole("button", { name: "Add Hill listener" })); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toEqual([{ kind: "observer", observerId, name: "Hill listener" }])); + expect(await screen.findByRole("button", { name: "Hill listener is saved" })).toBeDisabled(); + }); + + it("links an observer result to an already pinned node instead of adding a twin", async () => { + vi.mocked(api.getObserverDirectoryPage).mockResolvedValue(directory([listener])); + vi.mocked(api.getObserver).mockResolvedValue({ ...listener, publicKey: pin.publicKey } as never); + save([pin]); + mount(); + await pickField("Observer"); + typeSearch("hill"); + fireEvent.click(await screen.findByRole("button", { name: "Add Hill listener" })); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins).toEqual([{ kind: "node", ...pin, observerId }])); + expect(screen.getByRole("button", { name: "Hill listener is saved" })).toBeDisabled(); + }); + + it("marks node results that are also observers", async () => { + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, isObserver: true, observerId }], hasMore: false, nextCursor: null }); + mount(); + typeSearch("repeater"); + expect(await screen.findByText("Observer")).toHaveClass("inline-flex"); + }); + + it("keeps one card away from its default view at a time", async () => { + vi.mocked(api.getNode).mockImplementation(async (id) => ({ ...(id === second.id ? { ...node, ...second } : node), isObserver: true, observerId })); + vi.mocked(api.getObserver).mockResolvedValue({ id: observerId, status: "online", publicKey: pin.publicKey } as never); + save([pin, second]); + mount(); + const first = await card("Renamed repeater"); + fireEvent.click(within(first).getByRole("button", { name: "Observer" })); + expect(within(first).getByRole("button", { name: "Observer" })).toHaveAttribute("aria-pressed", "true"); + const other = await card("Second"); + fireEvent.click(within(other).getByRole("button", { name: "Heard by" })); + expect(within(first).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); + expect(within(other).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "true"); + }); + it("keeps one heard-by open, saves reordering, removal and the range", async () => { save([pin, second]); mount(); @@ -173,9 +245,9 @@ describe("My Atlas", () => { fireEvent.click(within(first).getByRole("button", { name: "Heard by" })); fireEvent.click(within(next).getByRole("button", { name: "Heard by" })); expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "false"); - expect(within(next).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); + expect(within(next).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "true"); fireEvent.keyDown(within(first).getByRole("button", { name: "Drag to reorder" }), { key: "ArrowRight" }); - expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].publicKey).toBe(second.publicKey); fireEvent.click(screen.getByRole("button", { name: "3d" })); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d"); @@ -247,7 +319,7 @@ describe("identity recovery", () => { save(); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].id).toBe(second.id)); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).pins[0].id).toBe(second.id)); }); it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => { vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey }); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts index e82e7793..e7e99fbe 100644 --- a/tests/features/atlas/atlas.test.ts +++ b/tests/features/atlas/atlas.test.ts @@ -1,22 +1,70 @@ -import { describe, expect, it } from "vitest"; -import { parseAtlas, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; +import { describe, expect, it, vi } from "vitest"; +import * as api from "../../../src/api/client"; +import { parseAtlas, pinKey, resolveObserverNode, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; import type { NodeObservation } from "../../../src/features/nodes/types"; +import type { Observer } from "../../../src/features/observers/types"; + +vi.mock("../../../src/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + getNodesPage: vi.fn(), +})); + +describe("resolveObserverNode", () => { + it("resolves an observer to the node with the same key and to nothing otherwise", async () => { + const key = "ab".repeat(32); + const node = { id: "11111111-1111-4111-8111-111111111111", publicKey: key.toUpperCase(), name: "R1" }; + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, publicKey: "ab".repeat(31) + "ff" }, node], hasMore: false, nextCursor: null } as never); + await expect(resolveObserverNode({ publicKey: key } as Observer)).resolves.toMatchObject({ id: node.id }); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: key, limit: 2 }, undefined); + await expect(resolveObserverNode({ publicKey: "" } as Observer)).resolves.toBeNull(); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null } as never); + await expect(resolveObserverNode({ publicKey: key } as Observer)).resolves.toBeNull(); + }); +}); const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" }; +const nodePin = { kind: "node", ...pin, publicKey: pin.publicKey.toLowerCase() }; const now = Date.UTC(2026, 8, 29, 12); const report = (id: number, hours: number, extra: Partial = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra }); describe("saved Atlas", () => { it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { - expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }); + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 2, range: "3d", pins: [nodePin, { ...nodePin, publicKey: "cd".repeat(32) }] }); }); it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => { - for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]); + for (const value of [null, "{", "[]", '{"version":3}', '{"version":1,"nodes":null}', '{"version":2,"pins":{}}']) expect(parseAtlas(value).pins).toEqual([]); const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))]; const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes })); - expect(result.nodes).toHaveLength(ATLAS_LIMIT); + expect(result.pins).toHaveLength(ATLAS_LIMIT); expect(result.range).toBe("24h"); }); + it("migrates a version 1 collection to node pins in order and keeps the range", () => { + const raw = JSON.stringify({ version: 1, range: "3d", nodes: [ + { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "One" }, + { id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Two" }, + ] }); + expect(parseAtlas(raw)).toEqual({ version: 2, range: "3d", pins: [ + { kind: "node", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "One" }, + { kind: "node", id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Two" }, + ] }); + }); + it("round-trips version 2 pins of both kinds, keeps the observer hint, and drops unknown kinds", () => { + const pins = [ + { kind: "node", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "One", observerId: "33333333-3333-4333-8333-333333333333" }, + { kind: "observer", observerId: "44444444-4444-4444-8444-444444444444", name: "Listener" }, + { kind: "channel", id: "x", name: "Nope" }, + { kind: "observer", observerId: "not-a-uuid", name: "Bad" }, + { kind: "node", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Twin", observerId: "bad" }, + ]; + expect(parseAtlas(JSON.stringify({ version: 2, range: "24h", pins }))).toEqual({ version: 2, range: "24h", pins: [pins[0], pins[1]] }); + expect(pinKey(pins[0] as never)).toBe("ab".repeat(32)); + expect(pinKey(pins[1] as never)).toBe("44444444-4444-4444-8444-444444444444"); + }); + it("dedupes observer pins by id and caps both kinds together at the limit", () => { + const observer = (n: number) => ({ kind: "observer", observerId: `${String(n).padStart(8, "0")}-0000-4000-8000-000000000000`, name: `L${n}` }); + const pins = [observer(1), observer(1), ...Array.from({ length: 14 }, (_, i) => observer(i + 2))]; + expect(parseAtlas(JSON.stringify({ version: 2, range: "24h", pins })).pins).toHaveLength(ATLAS_LIMIT); + }); }); describe("origin report sample", () => { diff --git a/tests/features/atlas/observer-sample.test.ts b/tests/features/atlas/observer-sample.test.ts new file mode 100644 index 00000000..06c25bde --- /dev/null +++ b/tests/features/atlas/observer-sample.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { summarizeObserver, OBSERVER_RANGE } from "../../../src/features/atlas/observer-sample"; +import type { ObserverActivity, ObserverTelemetry } from "../../../src/features/stats/types"; +import type { Observer } from "../../../src/features/observers/types"; + +const HOUR = 3_600_000; +const now = Date.UTC(2026, 9, 13, 12, 30); +const point = (hoursAgo: number, observations: number | null, snrAvg: number | null) => + ({ t: now - hoursAgo * HOUR, observations, snrAvg, snrMin: null, rssiAvg: null, airtimeMs: null }); +const activity = (points: ReturnType[]) => ({ range: "24h", interval: "1h", radio: null, payloadTypes: [], points }) as ObserverActivity; +const tele = (points: Partial[]) => ({ range: "24h", interval: "1h", points: points.map((p) => ({ + t: now, batteryMv: null, airtimeTxSecs: null, airtimeRxSecs: null, noiseFloorDb: null, uptimeSeconds: null, queueLength: null, receiveErrors: null, ...p })) }) as ObserverTelemetry; + +describe("summarizeObserver", () => { + it("maps ranges to the server's duration strings", () => { + expect(OBSERVER_RANGE).toEqual({ "24h": "24h", "3d": "72h" }); + }); + + it("bins packets heard per hour over the window, newest last, and sums them", () => { + const sample = summarizeObserver(activity([point(0, 3, 1), point(1, 5, 2), point(30, 99, 0)]), undefined, undefined, "24h", now); + expect(sample.counts).toHaveLength(24); + expect(sample.counts[23]).toBe(3); + expect(sample.counts[22]).toBe(5); + expect(sample.counts[0]).toBe(0); + expect(sample.heard).toBe(8); + }); + + it("weights the mean SNR by packets and leaves silent hours null", () => { + const sample = summarizeObserver(activity([point(0, 1, 10), point(1, 3, 2), point(2, 0, 7), point(3, null, null)]), undefined, undefined, "24h", now); + expect(sample.snr).toBeCloseTo(4); + expect(sample.snrHourly[23]).toBe(10); + expect(sample.snrHourly[21]).toBeNull(); + expect(sample.counts[20]).toBeNull(); + }); + + it("takes the latest noise floor and battery, ignoring all-zero rows", () => { + const sample = summarizeObserver(undefined, tele([ + { t: now - 2 * HOUR, noiseFloorDb: -118, batteryMv: 4050 }, + { t: now - HOUR, noiseFloorDb: 0, batteryMv: 0 }, + { t: now - 3 * HOUR, noiseFloorDb: -121, batteryMv: null }, + ]), undefined, "24h", now); + expect(sample.noise).toBe(-118); + expect(sample.battery).toBeCloseTo(4.05); + expect(sample.noiseHourly[20]).toBe(-121); + expect(sample.noiseHourly[21]).toBe(-118); + expect(sample.noiseHourly[22]).toBeNull(); + }); + + it("treats a zero battery reading on an otherwise live row as no battery", () => { + const sample = summarizeObserver(undefined, tele([{ t: now - HOUR, batteryMv: 0, noiseFloorDb: -96, uptimeSeconds: 5 }]), undefined, "24h", now); + expect(sample.noise).toBe(-96); + expect(sample.battery).toBeNull(); + expect(sample.batteryHourly[22]).toBeNull(); + expect(sample.uptime).toBe(5); + }); + + it("prefers the observer record's uptime and gives nulls, not NaN, on empty input", () => { + const empty = summarizeObserver(undefined, undefined, undefined, "3d", now); + expect(empty).toMatchObject({ heard: null, snr: null, noise: null, battery: null, uptime: null }); + expect(empty.counts).toHaveLength(72); + const sample = summarizeObserver(undefined, tele([{ uptimeSeconds: 10 }]), { uptimeSeconds: 99 } as Observer, "24h", now); + expect(sample.uptime).toBe(99); + }); +}); diff --git a/tests/features/atlas/useAtlasObserver.test.tsx b/tests/features/atlas/useAtlasObserver.test.tsx new file mode 100644 index 00000000..2675d445 --- /dev/null +++ b/tests/features/atlas/useAtlasObserver.test.tsx @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { act, renderHook } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import * as api from "../../../src/api/client"; +import { ApiError } from "../../../src/api/client"; +import { shouldRetryQuery } from "../../../src/api/rate-limit"; +import { ATLAS_REFRESH_MS } from "../../../src/features/atlas/useAtlas"; +import { useAtlasObserver } from "../../../src/features/atlas/useAtlasObserver"; +import type { Observer } from "../../../src/features/observers/types"; + +vi.mock("../../../src/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + getObserver: vi.fn(), + getObserverActivity: vi.fn(), + getObserverTelemetry: vi.fn(), +})); + +const id = "33333333-3333-4333-8333-333333333333"; +const observer = { id, status: "online", iata: "YKF", publicKey: "ab".repeat(32), firstSeen: 1, lastSeen: 2, observationCount: 5, brokers: [], uptimeSeconds: 400 } as Observer; +let client: QueryClient; +const wrapper = ({ children }: { children: ReactNode }) => {children}; +const tick = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); }); + +beforeEach(() => { + vi.useFakeTimers(); + client = new QueryClient({ defaultOptions: { queries: { retry: shouldRetryQuery } } }); + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getObserverActivity).mockResolvedValue({ range: "24h", interval: "1h", radio: null, payloadTypes: [], points: [{ t: Date.now(), observations: 4, snrAvg: 3, snrMin: null, rssiAvg: null, airtimeMs: null }] }); + vi.mocked(api.getObserverTelemetry).mockResolvedValue({ range: "24h", interval: "1h", points: [] }); +}); +afterEach(() => { + client.clear(); + vi.useRealTimers(); + vi.clearAllMocks(); +}); + +describe("useAtlasObserver", () => { + it("loads the observer right away but activity and telemetry only while the view is open", async () => { + const { result, rerender } = renderHook(({ open }) => useAtlasObserver(id, "24h", true, open), { wrapper, initialProps: { open: false } }); + await tick(10); + expect(result.current.observer.data).toEqual(observer); + expect(api.getObserverActivity).not.toHaveBeenCalled(); + rerender({ open: true }); + await tick(10); + expect(result.current.sample.heard).toBe(4); + expect(api.getObserverActivity).toHaveBeenCalledWith(id, "24h", "1h", undefined); + expect(api.getObserverTelemetry).toHaveBeenCalledWith(id, "24h", "1h"); + expect(result.current.sample.uptime).toBe(400); + }); + + it("polls open queries once a minute and asks for 72h at the 3d window", async () => { + const { result } = renderHook(() => useAtlasObserver(id, "3d", true, true), { wrapper }); + await tick(10); + expect(result.current.sample.heard).toBe(4); + expect(api.getObserverActivity).toHaveBeenLastCalledWith(id, "72h", "1h", undefined); + await tick(ATLAS_REFRESH_MS + 50); + expect(api.getObserverActivity).toHaveBeenCalledTimes(2); + expect(api.getObserver).toHaveBeenCalledTimes(2); + }); + + it("treats a 404 as an unsupported server: dashes, no retry, no further polling of that query", async () => { + vi.mocked(api.getObserverActivity).mockRejectedValue(new ApiError(404, "not_found", "no")); + const { result } = renderHook(() => useAtlasObserver(id, "24h", true, true), { wrapper }); + await tick(10); + expect(result.current.unsupported).toBe(true); + expect(result.current.sample.heard).toBeNull(); + await tick(ATLAS_REFRESH_MS + 50); + expect(api.getObserverActivity).toHaveBeenCalledTimes(1); + expect(api.getObserverTelemetry).toHaveBeenCalledTimes(2); + }); + + it("does nothing without an observer id", () => { + renderHook(() => useAtlasObserver(null, "24h", true, true), { wrapper }); + expect(api.getObserver).not.toHaveBeenCalled(); + }); +});