diff --git a/.env.example b/.env.example index 800e481..8941de8 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 # Reveal normally-hidden themes in the picker (comma list of theme ids). Unset = none. diff --git a/docs/my-atlas.md b/docs/my-atlas.md new file mode 100644 index 0000000..e3b6985 --- /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 82424da..8c8dc80 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. @@ -297,6 +298,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 { @@ -283,6 +283,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", { @@ -295,7 +296,7 @@ export function getNodesPage( supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, - }); + }, signal); } // Paginated /observers, mirroring getNodesPage; used by the Observers table. @@ -315,18 +316,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 ab2896a..333bfc5 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -4,13 +4,13 @@ import { BottomSheet } from "./BottomSheet"; import { ENABLED_TABS } from "../lib/constants"; // 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 ( @@ -105,9 +105,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 0000000..1ddd095 --- /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 0000000..ed5beb4 --- /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 0000000..7662b65 --- /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 0000000..f71dadb --- /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 40886a6..d95da86 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 cf5e1f5..61cddbc 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", @@ -901,6 +902,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 6ad52b1..fff117f 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", @@ -937,6 +938,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 6701ea6..0f9bf2e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -22,7 +22,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 .env config (VITE_* prefix is required so the build can expose it to the browser; // the Docker entrypoint sed-substitutes each sentinel at container start — see .build/). diff --git a/tests/components/BottomNav.test.tsx b/tests/components/BottomNav.test.tsx index be2c039..211ecd0 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 0000000..3c19fec --- /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 0000000..35e1c01 --- /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 328fdd6..538dd1c 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", ]); });