diff --git a/.env.example b/.env.example index 50ce1659..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,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/.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/package-lock.json b/package-lock.json index cad2d8a9..71aa4a57 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.5", + "version": "2.0.5-dev1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.5", + "version": "2.0.5-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 c983950e..de590fc0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.5", + "version": "2.0.5-dev1.1", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/App.tsx b/src/App.tsx index 2a4bc6bd..b648d4bd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,8 +34,9 @@ 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 }))); // Stats pulls in ECharts (~150-200KB gz), so lazy-load it too — the chunk loads on first visit to Stats. @@ -302,6 +303,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); } // /info: the oldest client versions this server accepts (null = no requirement) and its own version. @@ -302,6 +302,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", { @@ -314,7 +315,7 @@ export function getNodesPage( supportsMultibytePaths: params?.supportsMultibytePaths, supportsMultibyteTraces: params?.supportsMultibyteTraces, neighbors: params?.neighbors ? "true" : undefined, - }); + }, signal); } export interface ObserverDirectoryRequest { @@ -374,18 +375,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/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 26bed734..36fb5a7e 100644 --- a/src/components/BottomNav.tsx +++ b/src/components/BottomNav.tsx @@ -6,12 +6,13 @@ 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; - +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 }; switch (name) { + case "MyAtlas": + return ; case "Packets": return ( diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx new file mode 100644 index 00000000..e5a776ad --- /dev/null +++ b/src/features/atlas/AtlasCard.tsx @@ -0,0 +1,337 @@ +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 { 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, 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; + onAnalyzePacket: (hash: string, observationId?: number) => void; +} + +interface AtlasCardProps extends AtlasActions { + pin: AtlasPin; + range: AtlasRange; + active: boolean; + view: AtlasView; + onViewChange: (view: AtlasView) => void; + onRemove: () => void; + onResolved: (id: string, observerId?: string) => void; + onUpgrade: (node: NodeSummary, observerId: string) => void; + handle: Partial; +} + +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, + view, + onViewChange, + onRemove, + onResolved, + onUpgrade, + handle, + onViewNode, + onViewObserver, + onAnalyzePacket, +}: AtlasCardProps) { + const { t } = useTranslation(); + const c = useChartColors(); + 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(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() === nodePin?.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, 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 (!nodePin || !node) return; + const linked = node.observerId ?? nodePin.observerId; + if (node.id !== nodePin.id || linked !== nodePin.observerId) onResolved(node.id, linked); + }, [node, nodePin, onResolved]); + + // 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 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")} + )} + + ); + + const actions = ( +
+ {views.length > 1 && ( + changeView(next as AtlasView)} + options={views.map((value) => ({ value, label: t(VIEW_LABEL[value]) }))} + /> + )} + + +
+ ); + + const retry = (refetch: () => Promise) => ( +

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

+ ); + const dash = "—"; + + return ( + + {pin.kind === "node" && query.isPending &&

{t("common.loading")}

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

{t("nodeDetail.notFound")}

+ )} + {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")}

+ )} + {pin.kind === "observer" && obs.observer.isError && !isNotFound(obs.observer.error) && retry(obs.observer.refetch)} + {view === "analytics" && node && ( +
+
+ + + + +
+
+ {node.lastSeen > 0 && ( + + {t("atlas.lastSeen")} + + )} + + {t("atlas.meanHops")} {average(sample.hops)} + + + {t("nodeDetail.neighborsCount", { count: node.knownNeighborCount })}{" "} + + +
+
+ )} + {view === "heard" && node && ( +
+ {!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} +
  • + ))} +
+ + + )} +
+ )} + {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 new file mode 100644 index 00000000..86fe5fda --- /dev/null +++ b/src/features/atlas/MyAtlasPage.tsx @@ -0,0 +1,262 @@ +import { useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getNodesPage, getObserver, getObserverDirectoryPage } from "../../api/client"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +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 { 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, type AtlasView } from "./AtlasCard"; +import { ATLAS_LIMIT, parseAtlas, pinKey, resolveObserverNode, type AtlasPin, type AtlasRange } from "./atlas"; +import { useCardDrag } from "./useCardDrag"; +import { useSavedAtlas } from "./useSavedAtlas"; + +const RANGES: AtlasRange[] = ["24h", "3d"]; +const SEARCH_LIMIT = 8; +const SEARCH_MIN = 2; + +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(); + // 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"); + // 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); + setSearch(""); + }; + + // 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 + : 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: 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.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 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 reorder = (from: number, to: number) => + update((value) => { + 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.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 ( +
+
+ + {!isMobile && } + + + + {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.pins.length, limit: ATLAS_LIMIT })} + + + {storageFailed && {t("atlas.storageFailed")}} + {!isMobile && {t("atlas.range")}} + update((value) => ({ ...value, range: range as AtlasRange }))} + ariaLabel={t("atlas.range")} + /> + +
+
+
+ {typed.length > 0 && ( + +
+ {hint ? ( +

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

+ ) : results.isPending ? ( +

{t("common.loading")}

+ ) : results.isError ? ( +

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

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

{t("common.noMatches")}

+ ) : results.data.kind === "node" ? ( +
    + {results.data.items.map((node) => { + const pinned = saved.pins.some((pin) => pin.kind === "node" && pin.publicKey === node.publicKey.toLowerCase()); + const label = node.name ?? formatHex(node.id); + return ( +
  • + + {label} + {node.nodeTypeName} + {node.isObserver && {t("atlas.observerBadge")}} + + {formatHex(node.publicKey)} + + + {addButton(pinned, label, () => add(node))} +
  • + ); + })} +
+ ) : ( +
    + {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 })}

+ )} + {full && !hint &&

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

} +
+
+ )} + {saved.pins.length === 0 ? ( + + ) : ( +
+ {saved.pins.map((pin, index) => ( +
+ 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 new file mode 100644 index 00000000..0ddf248f --- /dev/null +++ b/src/features/atlas/atlas.ts @@ -0,0 +1,153 @@ +import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from "../../api/client"; +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 NodePin { kind: "node"; id: string; publicKey: string; name: string; observerId?: string } +export interface ObserverPin { kind: "observer"; observerId: string; name: string } +export type AtlasPin = NodePin | ObserverPin; +export interface SavedAtlas { version: 2; range: AtlasRange; pins: AtlasPin[] } + +const UUID = /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i; +const KEY = /^[\da-f]{64}$/i; +const isUuid = (value: unknown): value is string => typeof value === "string" && UUID.test(value); + +export const pinKey = (pin: AtlasPin) => (pin.kind === "node" ? pin.publicKey : pin.observerId); + +// Version 1 stored `nodes`; version 2 stores `pins` with a kind. Both load, anything else is empty. +export function parseAtlas(raw: string | null): SavedAtlas { + const empty: SavedAtlas = { version: 2, range: "24h", pins: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + const list = value?.version === 1 ? value.nodes : value?.version === 2 ? value.pins : null; + if (!Array.isArray(list)) return empty; + const pins: AtlasPin[] = []; + for (const entry of list) { + const pin = toPin(entry, value.version === 1); + if (!pin || pins.some((other) => pinKey(other) === pinKey(pin))) continue; + pins.push(pin); + if (pins.length === ATLAS_LIMIT) break; + } + return { version: 2, range: value.range === "3d" ? "3d" : "24h", pins }; + } catch { return empty; } +} + +function toPin(raw: unknown, legacy: boolean): AtlasPin | null { + if (!raw || typeof raw !== "object") return null; + const value = raw as Record; + const name = typeof value.name === "string" ? value.name.slice(0, 160) : ""; + const kind = legacy ? "node" : value.kind; + if (kind === "observer") return isUuid(value.observerId) ? { kind, observerId: value.observerId, name } : null; + if (kind !== "node" || !isUuid(value.id) || typeof value.publicKey !== "string" || !KEY.test(value.publicKey)) return null; + const pin: NodePin = { kind, id: value.id, publicKey: value.publicKey.toLowerCase(), name }; + if (isUuid(value.observerId)) pin.observerId = value.observerId; + return pin; +} + +// The same radio can be a node and an observer; when it advertises, the node is the pin. +export async function resolveObserverNode(observer: Observer, signal?: AbortSignal): Promise { + const key = observer.publicKey?.toLowerCase(); + if (!key) return null; + const page = await getNodesPage(undefined, { pubkeyPrefix: key, limit: 2 }, signal); + return page.items.find((row) => row.publicKey.toLowerCase() === key) ?? null; +} + +// Resolve by full identity after a server database reset; never adopt a different node at an old ID. +export async function loadAtlasNode(pin: NodePin, signal?: AbortSignal) { + let node: Node | undefined; + 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 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"); + const page = await getNodeObservations(node.id, { limit: REPORT_LIMIT }, signal); + return { node, page }; +} + +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) || !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) { + // 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 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, + }; +} 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 new file mode 100644 index 00000000..8601169f --- /dev/null +++ b/src/features/atlas/useAtlas.ts @@ -0,0 +1,30 @@ +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 NodePin, type AtlasRange } from "./atlas"; + +export const ATLAS_REFRESH_MS = 60_000; + +// 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 ?? null], + queryFn: ({ signal }) => loadAtlasNode(pin!, signal), + enabled: pin !== null && 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/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/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/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts new file mode 100644 index 00000000..5e74d362 --- /dev/null +++ b/src/features/atlas/useSavedAtlas.ts @@ -0,0 +1,36 @@ +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 b01ca2ab..2568a311 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/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/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 9a63f483..917e5f8b 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..7b14d1ad 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} @@ -88,6 +90,7 @@ export function StatCard({ markPeak, presence, sublabel, + compact, }: { label: string; value: ReactNode; @@ -96,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/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 2a858a85..f6b447e4 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", @@ -954,6 +955,64 @@ }, "units": { "perDay": "{{value}}/d" + }, + "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.", + "refine": "Showing the first {{limit}} matches; refine the search for more.", + "add": "Add", + "saved": "Saved", + "addName": "Add {{name}}", + "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" + }, + "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", + "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.", + "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 c00b5e4a..9f303cf3 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", @@ -955,6 +956,66 @@ }, "units": { "perDay": "{{value}}/j" + }, + "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.", + "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", + "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.", + "refine": "{{limit}} premiers résultats affichés ; précisez la recherche pour en voir d’autres.", + "add": "Ajouter", + "saved": "Enregistré", + "addName": "Ajouter {{name}}", + "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" + }, + "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", + "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", + "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/src/lib/constants.ts b/src/lib/constants.ts index 317b56b4..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 = ["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 0c78aa5c..a830cffc 100644 --- a/tests/components/BottomNav.test.tsx +++ b/tests/components/BottomNav.test.tsx @@ -4,6 +4,16 @@ import { BottomNav } from "../../src/components/BottomNav"; import i18n from "../../src/i18n"; describe("BottomNav", () => { + 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").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.getByText("Plus")); + 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(); render(); diff --git a/tests/features/atlas/AtlasCard.test.tsx b/tests/features/atlas/AtlasCard.test.tsx new file mode 100644 index 00000000..3dbed82b --- /dev/null +++ b/tests/features/atlas/AtlasCard.test.tsx @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +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) => ({ + ...(await importOriginal()), + getNode: vi.fn(), + getNodeObservations: vi.fn(), + getNodesPage: vi.fn(), + getPacketDetail: vi.fn(), + getObserver: vi.fn(), + getObserverActivity: vi.fn(), + getObserverTelemetry: vi.fn(), +})); + +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: [ + { 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 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(), + onViewChange: vi.fn(), + onRemove: vi.fn(), + onResolved: vi.fn(), + onUpgrade: 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")).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("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, undefined); + }); + + 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("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: "" } }); + 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("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 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: "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"); + 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 () => { + vi.mocked(api.getPacketDetail).mockResolvedValue({ ...detail, originPubkey: "cd".repeat(32) }); + mount({ view: "heard" }); + 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 draggable", async () => { + vi.mocked(api.getNode).mockRejectedValue(new Error("down")); + mount({ handle: { "data-grip": "yes" } as Record }); + expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load"); + 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(); + }); + + 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 new file mode 100644 index 00000000..189378b9 --- /dev/null +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -0,0 +1,333 @@ +import { beforeEach, afterEach, describe, it, expect, vi } from "vitest"; +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; +import { ATLAS_KEY, loadAtlasNode } from "../../../src/features/atlas/atlas"; +import * 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", async (importOriginal) => ({ + ...(await importOriginal()), + getNodesPage: vi.fn(), + 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" }; +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 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) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + clients.push(client); + return render(); +} +function save(nodes = [pin], range = "24h") { + 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); + +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" }, + ] } as unknown as PacketDetail); +}); +afterEach(() => { + clients.splice(0).forEach((client) => client.clear()); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("My Atlas", () => { + 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("still loads a version 1 collection", async () => { + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 1, nodes: [pin], range: "24h" })); + mount(); + expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); + }); + + it("keeps the search in a toolbar with the saved count chip beside the range picker, and lists matches only while typing", async () => { + save(); + mount(); + await screen.findByRole("button", { name: "Renamed repeater" }); + const bar = screen.getByRole("toolbar", { name: "Add nodes" }); + 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(); + expect(api.getNode).not.toHaveBeenCalled(); + expect(api.getNodesPage).not.toHaveBeenCalled(); + }); + + 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(); + typeSearch("repeater"); + fireEvent.click(await screen.findByRole("button", { name: "Add Renamed repeater" })); + 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)!).pins).toHaveLength(1); + 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("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("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(); + 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-pressed", "false"); + 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)!).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"); + 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 () => { + 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 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("button", { name: "Renamed repeater" }); + expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); + act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); + expect(screen.getByText("No 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("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); + }); +}); + +describe("identity recovery", () => { + it("recovers a changed server ID using the exact full public key", async () => { + 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)!).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 }); + vi.mocked(api.getNodesPage).mockResolvedValueOnce({ items: [], nextCursor: null, hasMore: false }); + 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"); + 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..e7e99fbe --- /dev/null +++ b/tests/features/atlas/atlas.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest"; +import * as api from "../../../src/api/client"; +import { parseAtlas, pinKey, resolveObserverNode, summarizeReports, ATLAS_LIMIT } from "../../../src/features/atlas/atlas"; +import type { NodeObservation } from "../../../src/features/nodes/types"; +import type { Observer } from "../../../src/features/observers/types"; + +vi.mock("../../../src/api/client", async (importOriginal) => ({ + ...(await importOriginal()), + getNodesPage: vi.fn(), +})); + +describe("resolveObserverNode", () => { + it("resolves an observer to the node with the same key and to nothing otherwise", async () => { + const key = "ab".repeat(32); + const node = { id: "11111111-1111-4111-8111-111111111111", publicKey: key.toUpperCase(), name: "R1" }; + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [{ ...node, publicKey: "ab".repeat(31) + "ff" }, node], hasMore: false, nextCursor: null } as never); + await expect(resolveObserverNode({ publicKey: key } as Observer)).resolves.toMatchObject({ id: node.id }); + expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { pubkeyPrefix: key, limit: 2 }, undefined); + await expect(resolveObserverNode({ publicKey: "" } as Observer)).resolves.toBeNull(); + vi.mocked(api.getNodesPage).mockResolvedValue({ items: [], hasMore: false, nextCursor: null } as never); + await expect(resolveObserverNode({ publicKey: key } as Observer)).resolves.toBeNull(); + }); +}); + +const pin = { id: "11111111-1111-4111-8111-111111111111", publicKey: "AB".repeat(32), name: "My repeater" }; +const nodePin = { kind: "node", ...pin, publicKey: pin.publicKey.toLowerCase() }; +const now = Date.UTC(2026, 8, 29, 12); +const report = (id: number, hours: number, extra: Partial = {}): NodeObservation => ({ id, packetHash: String(id).padStart(64, "0"), payloadType: 4, payloadTypeName: "ADVERT", iata: "YKF", heardAt: now - hours * 3_600_000, ...extra }); + +describe("saved Atlas", () => { + it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 2, range: "3d", 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":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.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", () => { + 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, 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/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/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/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(); + }); +}); 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); + }); +}); 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 6255e2f4..cf5be95f 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -1,6 +1,24 @@ 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("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(); 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 328fdd6e..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([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas", ]); expect(filterEnabledTabs(TABS, "MAP , NODES")).toEqual([ - "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", + "Packets", "Channels", "Observers", "Routes", "Traces", "Analytics", "MyAtlas", ]); });