From df7d4fec991658f086f6f9cfb6ac2ae1532a3162 Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 14 Oct 2026 09:20:53 -0400 Subject: [PATCH 1/2] Add saved observer cards and gap-aware histories to My Atlas --- package-lock.json | 4 +- package.json | 2 +- src/api/client.ts | 11 +- src/features/atlas/AtlasCard.tsx | 31 +++-- src/features/atlas/AtlasObserverCard.tsx | 72 ++++++++++ src/features/atlas/MyAtlasPage.tsx | 127 ++++++++++-------- src/features/atlas/atlas-storage.ts | 35 +++++ src/features/atlas/atlas.ts | 29 +--- src/features/atlas/observer.ts | 45 +++++++ src/features/atlas/useCardDrag.ts | 21 ++- src/features/atlas/useSavedAtlas.ts | 4 +- src/features/stats/cards.tsx | 13 +- src/i18n/locales/en.json | 23 +++- src/i18n/locales/fr.json | 21 ++- .../features/atlas/AtlasObserverCard.test.tsx | 81 +++++++++++ tests/features/atlas/MyAtlasPage.test.tsx | 14 +- tests/features/atlas/atlas.test.ts | 6 +- tests/features/atlas/observer.test.ts | 57 ++++++++ tests/features/atlas/useCardDrag.test.tsx | 51 ++++++- tests/features/stats/cards.test.tsx | 8 ++ 20 files changed, 515 insertions(+), 140 deletions(-) create mode 100644 src/features/atlas/AtlasObserverCard.tsx create mode 100644 src/features/atlas/atlas-storage.ts create mode 100644 src/features/atlas/observer.ts create mode 100644 tests/features/atlas/AtlasObserverCard.test.tsx create mode 100644 tests/features/atlas/observer.test.ts diff --git a/package-lock.json b/package-lock.json index 71aa4a5..3aa8c61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.0.5-dev1.1", + "version": "2.1.0-dev1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.0.5-dev1.1", + "version": "2.1.0-dev1.3", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index de590fc..14079fb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.0.5-dev1.1", + "version": "2.1.0-dev1.3", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/api/client.ts b/src/api/client.ts index 2f0da47..529fbb9 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -266,8 +266,8 @@ export function getTraceDetail(tag: string): Promise { return request(`/traces/${tag}`); } -export async function getObserver(observerId: string): Promise { - const observer = await request(`/observers/${observerId}`); +export async function getObserver(observerId: string, signal?: AbortSignal): Promise { + const observer = await request(`/observers/${observerId}`, undefined, signal); // Servers before json.RawMessage (and cache entries written by them) send the stored JSON as base64. let metadata: unknown = observer.statusMetadata; if (typeof metadata === "string") { @@ -456,12 +456,13 @@ export function getObserverTelemetry( range: string, interval?: string, afterId?: number, + signal?: AbortSignal, ): Promise { - return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); + return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }, signal); } -export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise { - return request(`/observers/${observerId}/activity`, { range, interval, until }); +export function getObserverActivity(observerId: string, range: string, interval: string, until?: number, signal?: AbortSignal): Promise { + return request(`/observers/${observerId}/activity`, { range, interval, until }, signal); } // Lets a caller hide a feature the server doesn't have rather than show it as failed. diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 4336f2d..3d40d53 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -125,18 +125,7 @@ export function AtlasCard({ - + ); @@ -245,3 +234,21 @@ export function AtlasCard({ ); } + +export function AtlasDragHandle({ handle }: { handle: Partial }) { + const { t } = useTranslation(); + return ( + + ); +} diff --git a/src/features/atlas/AtlasObserverCard.tsx b/src/features/atlas/AtlasObserverCard.tsx new file mode 100644 index 0000000..7bc2874 --- /dev/null +++ b/src/features/atlas/AtlasObserverCard.tsx @@ -0,0 +1,72 @@ +import { useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserverActivity, getObserverTelemetry, isNotFound } from "../../api/client"; +import { ACTION_BUTTON_CLASS } from "../../components/action-button"; +import { Badge } from "../../components/Badge"; +import { InfoTip } from "../../components/InfoTip"; +import { Timestamp } from "../../components/Timestamp"; +import { useRateLimit } from "../../hooks/useRateLimit"; +import { useTick } from "../../hooks/useTick"; +import { formatBattery, formatCount, formatUptime } from "../../lib/formatters"; +import { observerNoiseFloor } from "../observers/observer-stats"; +import { deriveObserverStatus } from "../observers/observer-status"; +import { Card, StatCard } from "../stats/cards"; +import { useChartColors } from "../stats/chartTheme"; +import type { AtlasPin, AtlasRange } from "./atlas-storage"; +import { finite, latest, loadAtlasObserver, observerActivitySeries, observerTelemetrySeries } from "./observer"; +import type { ReactNode } from "react"; + +export function AtlasObserverCard({ pin, range, active, onRemove, onResolved, onViewObserver, reorder }: { + pin: AtlasPin; range: AtlasRange; active: boolean; onRemove: () => void; + onResolved: (id: string) => void; onViewObserver: (id: string) => void; reorder?: ReactNode; +}) { + const { t, i18n } = useTranslation(); + const colors = useChartColors(); + const now = useTick(60_000); + const limitedUntil = useRateLimit(); + const enabled = active && !(limitedUntil != null && now < limitedUntil); + const polling = { staleTime: 60_000, refetchInterval: 60_000, refetchIntervalInBackground: false, refetchOnWindowFocus: false, retry: false } as const; + const observer = useQuery({ ...polling, queryKey: ["atlas-observer", pin.publicKey || pin.id], queryFn: ({ signal }) => loadAtlasObserver(pin, signal), enabled }); + const data = observer.data; + const identityReady = enabled && !!data && !observer.isError; + const duration = range === "3d" ? "72h" : "24h"; + const activity = useQuery({ ...polling, queryKey: ["atlas-observer-activity", data?.id, range], queryFn: ({ signal }) => getObserverActivity(data!.id, duration, "1h", undefined, signal), enabled: identityReady }); + const telemetry = useQuery({ ...polling, queryKey: ["atlas-observer-telemetry", data?.id, range], queryFn: ({ signal }) => getObserverTelemetry(data!.id, duration, "1h", undefined, signal), enabled: identityReady }); + useEffect(() => { if (data && data.id !== pin.id) onResolved(data.id); }, [data, pin.id, onResolved]); + const points = observerTelemetrySeries(telemetry.data?.points ?? [], range, now); + const heard = observerActivitySeries(activity.data, range, now); + const counts = heard.map(p => finite(p.observations) ? p.observations : null); + const count = counts.some(finite) ? counts.reduce((total, n) => total + (n ?? 0), 0) : null; + const snr = heard.map(p => finite(p.snrAvg) ? p.snrAvg : null); + const rssi = heard.map(p => finite(p.rssiAvg) ? p.rssiAvg : null); + const battery = finite(data?.batteryLevel) ? data.batteryLevel : latest(points.batteryMv) == null ? null : latest(points.batteryMv)! / 1000; + const uptime = finite(data?.uptimeSeconds) ? data.uptimeSeconds : latest(points.uptimeSeconds); + const noise = data ? observerNoiseFloor(data) ?? latest(points.noiseFloorDb) : null; + const measure = (value: number | null, unit: string) => value == null ? "—" : `${value.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} ${unit}`; + const failed = observer.isError || activity.isError || telemetry.isError; + const retry = () => { void observer.refetch(); if (data && !observer.isError) { void activity.refetch(); void telemetry.refetch(); } }; + const name = data?.displayName || pin.name || pin.id.slice(0, 8); + return + + ◉{t("atlas.observerCard")} + {data && {t(deriveObserverStatus(data, now) === "online" ? "atlas.recent" : "atlas.stale")}} + } right={
{reorder}
}> + {observer.isPending &&

{t("common.loading")}

} + {failed &&

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

} + {data && <> +
+ v == null) ? "atlas.observerPartialCoverage" : `atlas.ranges.${range}`)} /> + v == null ? null : v / 1000)} accent={colors.series[2]!} interpolateGaps /> + + + + +
+
+ {t("atlas.lastSeen")} {data.lastSeen > 0 ? : "—"} + +
+ } +
; +} diff --git a/src/features/atlas/MyAtlasPage.tsx b/src/features/atlas/MyAtlasPage.tsx index f6e152c..dc6b124 100644 --- a/src/features/atlas/MyAtlasPage.tsx +++ b/src/features/atlas/MyAtlasPage.tsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; -import { getNodesPage } from "../../api/client"; +import { getNodesPage, getObserver, getObserverDirectoryPage } from "../../api/client"; import { ACTION_BUTTON_CLASS } from "../../components/action-button"; import { Badge } from "../../components/Badge"; import { EmptyState } from "../../components/EmptyState"; @@ -14,16 +14,18 @@ import { nodeSearchParams } from "../nodes/node-search"; import type { NodeSummary } from "../nodes/types"; import { Card } from "../stats/cards"; import { Segmented } from "../stats/Segmented"; -import { AtlasCard, type AtlasActions } from "./AtlasCard"; -import { ATLAS_LIMIT, parseAtlas, type AtlasRange } from "./atlas"; +import { AtlasCard, AtlasDragHandle, type AtlasActions } from "./AtlasCard"; +import { ATLAS_LIMIT, atlasPinKey, parseAtlas, type AtlasPin, type AtlasRange } from "./atlas"; import { useCardDrag } from "./useCardDrag"; import { useSavedAtlas } from "./useSavedAtlas"; +import { AtlasObserverCard } from "./AtlasObserverCard"; +import type { ObserverDirectoryItem } from "../observers/types"; const RANGES: AtlasRange[] = ["24h", "3d"]; const SEARCH_LIMIT = 8; const SEARCH_MIN = 2; -// Global by design: pins are node identities, so the header region picker does not apply here. +// Global by design: pins are identities, so the header region picker does not apply here. export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { active?: boolean }) { const { t } = useTranslation(); const { saved, update, storageFailed } = useSavedAtlas(); @@ -31,6 +33,9 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti const isMobile = useIsMobile(); const [search, setSearch] = useState(""); const [field, setField] = useState("name"); + const observerMode = field === "observer"; + const [addingObserver, setAddingObserver] = useState(null); + const [addError, setAddError] = useState(false); const [heardKey, setHeardKey] = useState(null); // Switching the field changes what the box means, so stale text must not carry over. const changeField = (value: string) => { @@ -49,36 +54,59 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti const results = useQuery({ queryKey: ["atlas-search", params.name, params.pubkeyPrefix], queryFn: ({ signal }) => getNodesPage(undefined, { ...params, limit: SEARCH_LIMIT }, signal), - enabled: active && ready, + enabled: active && ready && !observerMode, staleTime: 30_000, }); - const full = saved.nodes.length >= ATLAS_LIMIT; + const observerSearch = useQuery({ + queryKey: ["atlas-observer-search", typed], + queryFn: ({ signal }) => getObserverDirectoryPage({ name: typed, sort: "name", limit: SEARCH_LIMIT }, signal), + enabled: active && ready && observerMode, + staleTime: 30_000, + }); + const matches = observerMode ? observerSearch : results; + const entries: { id: string; name: string; key: string; role: string; node?: NodeSummary; observer?: ObserverDirectoryItem }[] = observerMode + ? (observerSearch.data?.items ?? []).map(observer => ({ id: observer.id, name: observer.displayName || observer.id.slice(0, 8), key: observer.id, role: t("atlas.observerCard"), observer })) + : (results.data?.items ?? []).map(node => ({ id: node.id, name: node.name || formatHex(node.id), key: node.publicKey, role: node.nodeTypeName, node })); + const addObserver = async (row: ObserverDirectoryItem) => { + setAddingObserver(row.id); setAddError(false); + try { + const observer = await getObserver(row.id); + if (observer.id !== row.id) throw new Error("Observer identity changed"); + const pin: AtlasPin = { kind: "observer", id: observer.id, publicKey: observer.publicKey || "", name: observer.displayName || "" }; + if (!parseAtlas(JSON.stringify({ version: 2, cards: [pin] })).cards.length) throw new Error("Invalid observer identity"); + update(value => parseAtlas(JSON.stringify({ ...value, cards: [...value.cards, pin] }))); + } catch { setAddError(true); } + finally { setAddingObserver(null); } + }; + + const full = saved.cards.length >= ATLAS_LIMIT; const fields: SearchFieldOption[] = [ { value: "name", label: t("nodeFilters.fieldName") }, { value: "pubkey", label: t("nodeFilters.fieldPubkey") }, + { value: "observer", label: t("atlas.searchObservers") }, ]; const rangeOptions = RANGES.map((value) => ({ value, label: t(`atlas.ranges.${value}`) })); // Re-parse so an add goes through the same validation, dedupe and cap as a restore. const add = (node: NodeSummary) => update((value) => { - const pin = { id: node.id, publicKey: node.publicKey, name: node.name || "" }; - return parseAtlas(JSON.stringify({ ...value, nodes: [...value.nodes, pin] })); + const pin = { kind: "node", id: node.id, publicKey: node.publicKey, name: node.name || "" }; + return parseAtlas(JSON.stringify({ ...value, cards: [...value.cards, pin] })); }); - const remove = (publicKey: string) => - update((value) => ({ ...value, nodes: value.nodes.filter((node) => node.publicKey !== publicKey) })); - const relink = (publicKey: string, id: string) => - update((value) => ({ ...value, nodes: value.nodes.map((node) => (node.publicKey === publicKey ? { ...node, id } : node)) })); + const remove = (key: string) => + update((value) => ({ ...value, cards: value.cards.filter((pin) => atlasPinKey(pin) !== key) })); + const relink = (key: string, id: string) => + update((value) => ({ ...value, cards: value.cards.map((pin) => (atlasPinKey(pin) === key ? { ...pin, id } : pin)) })); const reorder = (from: number, to: number) => update((value) => { - const nodes = [...value.nodes]; + const nodes = [...value.cards]; const [moved] = nodes.splice(from, 1); - if (!moved || to < 0 || to >= value.nodes.length) return value; + if (!moved || to < 0 || to >= value.cards.length) return value; nodes.splice(to, 0, moved); - return { ...value, nodes }; + return { ...value, cards: nodes }; }); - const drag = useCardDrag(saved.nodes.length, reorder); + const drag = useCardDrag(saved.cards.length, reorder); const cardProps = (index: number) => { const props = drag.cardProps(index); return { ...props, className: `min-w-0 ${props.className ?? ""}` }; @@ -97,13 +125,13 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti fields={fields} field={field} onFieldChange={changeField} - inputLabel={t("atlas.search")} + inputLabel={t(observerMode ? "atlas.observerSearch" : "atlas.search")} /> {!isMobile && } - {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.nodes.length, limit: ATLAS_LIMIT })} + {t(isMobile ? "atlas.savedCountShort" : "atlas.savedCount", { count: saved.cards.length, limit: ATLAS_LIMIT })} {storageFailed && {t("atlas.storageFailed")}} @@ -122,74 +150,57 @@ export function MyAtlasPage({ active = true, ...actions }: AtlasActions & { acti {typed.length > 0 && (
+ {addError &&

{t("atlas.observerAddFailed")}

} {hint ? (

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

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

{t("common.loading")}

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

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

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

{t("common.noMatches")}

) : (
    - {results.data.items.map((node) => { - const pinned = saved.nodes.some((pin) => pin.publicKey === node.publicKey.toLowerCase()); - const label = node.name ?? formatHex(node.id); - return ( -
  • - - {label} - {node.nodeTypeName} - - {formatHex(node.publicKey)} - - - -
  • - ); + {entries.map((entry) => { + const pinned = saved.cards.some(pin => entry.observer ? pin.kind === "observer" && pin.id === entry.id : pin.kind !== "observer" && pin.publicKey === entry.key.toLowerCase()); + return
  • + {entry.name}{entry.role}{formatHex(entry.key)} + +
  • ; })}
)} - {results.data?.hasMore && !hint && ( + {matches.data?.hasMore && !hint && (

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

)} {full && !hint &&

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

}
)} - {saved.nodes.length === 0 ? ( + {saved.cards.length === 0 ? ( ) : (
- {saved.nodes.map((pin, index) => ( -
- ( +
+ {pin.kind === "observer" ? remove(atlasPinKey(pin))} onResolved={id => relink(atlasPinKey(pin), id)} onViewObserver={actions.onViewObserver} reorder={} /> : setHeardKey((key) => (key === pin.publicKey ? null : pin.publicKey))} - onRemove={() => remove(pin.publicKey)} - onResolved={(id) => relink(pin.publicKey, id)} + heardOpen={heardKey === atlasPinKey(pin)} + onHeardToggle={() => setHeardKey((key) => (key === atlasPinKey(pin) ? null : atlasPinKey(pin)))} + onRemove={() => remove(atlasPinKey(pin))} + onResolved={(id) => relink(atlasPinKey(pin), id)} handle={drag.handleProps(index)} {...actions} - /> + />}
))}
diff --git a/src/features/atlas/atlas-storage.ts b/src/features/atlas/atlas-storage.ts new file mode 100644 index 0000000..c2b0938 --- /dev/null +++ b/src/features/atlas/atlas-storage.ts @@ -0,0 +1,35 @@ +export const ATLAS_KEY = "beacon-my-atlas-v2"; +export const ATLAS_LEGACY_KEY = "beacon-my-atlas-v1"; +export const ATLAS_LIMIT = 12; +export type AtlasRange = "24h" | "3d"; +export interface AtlasPin { id: string; publicKey: string; name: string; kind?: "node" | "observer" } +export interface SavedAtlas { version: 2; range: AtlasRange; cards: AtlasPin[] } +const UUID = /^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i; +const KEY = /^[\da-f]{64}$/i; + +export const atlasPinKey = (pin: AtlasPin) => `${pin.kind ?? "node"}:${pin.publicKey || pin.id}`; + +// Read v1 without overwriting it: an older deployed frontend can still recover its node pins. +export function parseAtlas(raw: string | null): SavedAtlas { + const empty: SavedAtlas = { version: 2, range: "24h", cards: [] }; + try { + if (!raw || raw.length > 32_000) return empty; + const value = JSON.parse(raw); + const input = value?.version === 1 ? value.nodes : value?.version === 2 ? value.cards : null; + if (!Array.isArray(input)) return empty; + const cards: AtlasPin[] = []; + for (const item of input) { + if (!item || typeof item !== "object" || Array.isArray(item)) continue; + const kind = value.version === 1 ? "node" : item?.kind; + if (kind !== "node" && kind !== "observer") continue; + if (typeof item.id !== "string" || !(UUID.test(item.id) || (kind === "node" && item.id === ""))) continue; + if (kind === "observer" && item.id === "00000000-0000-0000-0000-000000000000") continue; + if (typeof item.publicKey !== "string" || !(KEY.test(item.publicKey) || (kind === "observer" && item.publicKey === ""))) continue; + const pin: AtlasPin = { kind, id: item.id.toLowerCase(), publicKey: item.publicKey.toLowerCase(), name: typeof item.name === "string" ? item.name.slice(0, 160) : "" }; + if (cards.some(card => atlasPinKey(card) === atlasPinKey(pin))) continue; + cards.push(pin); + if (cards.length === ATLAS_LIMIT) break; + } + return { version: 2, range: value.range === "3d" ? "3d" : "24h", cards }; + } catch { return empty; } +} diff --git a/src/features/atlas/atlas.ts b/src/features/atlas/atlas.ts index b6bc357..e64d571 100644 --- a/src/features/atlas/atlas.ts +++ b/src/features/atlas/atlas.ts @@ -1,37 +1,16 @@ import { ApiError, getNode, getNodeObservations, getNodesPage, isNotFound } from "../../api/client"; import type { Node, NodeObservation } from "../nodes/types"; -export const ATLAS_KEY = "beacon-my-atlas-v1"; -export const ATLAS_LIMIT = 12; +export { ATLAS_KEY, ATLAS_LEGACY_KEY, ATLAS_LIMIT, atlasPinKey, parseAtlas } from "./atlas-storage"; +export type { AtlasPin, AtlasRange, SavedAtlas } from "./atlas-storage"; +import type { AtlasPin, AtlasRange } from "./atlas-storage"; export const REPORT_LIMIT = 200; -export type AtlasRange = "24h" | "3d"; -export interface AtlasPin { id: string; publicKey: string; name: string } -export interface SavedAtlas { version: 1; range: AtlasRange; nodes: AtlasPin[] } - -export function parseAtlas(raw: string | null): SavedAtlas { - const empty: SavedAtlas = { version: 1, range: "24h", nodes: [] }; - try { - if (!raw || raw.length > 32_000) return empty; - const value = JSON.parse(raw); - if (value?.version !== 1 || !Array.isArray(value.nodes)) return empty; - const nodes: AtlasPin[] = []; - for (const pin of value.nodes) { - if (typeof pin?.id !== "string" || !/^[\da-f]{8}(-[\da-f]{4}){3}-[\da-f]{12}$/i.test(pin.id) || - typeof pin.publicKey !== "string" || !/^[\da-f]{64}$/i.test(pin.publicKey)) continue; - const publicKey = pin.publicKey.toLowerCase(); - if (nodes.some(node => node.publicKey === publicKey)) continue; - nodes.push({ id: pin.id, publicKey, name: typeof pin.name === "string" ? pin.name.slice(0, 160) : "" }); - if (nodes.length === ATLAS_LIMIT) break; - } - return { version: 1, range: value.range === "3d" ? "3d" : "24h", nodes }; - } catch { return empty; } -} // Resolve by full identity after a server database reset; never adopt a different node at an old ID. export async function loadAtlasNode(pin: AtlasPin, signal?: AbortSignal) { let node: Node | undefined; try { - node = await getNode(pin.id, signal); + if (pin.id) node = await getNode(pin.id, signal); } catch (error) { if (!isNotFound(error)) throw error; } diff --git a/src/features/atlas/observer.ts b/src/features/atlas/observer.ts new file mode 100644 index 0000000..1bc754a --- /dev/null +++ b/src/features/atlas/observer.ts @@ -0,0 +1,45 @@ +import { ApiError, getNodesPage, getObserver, isNotFound } from "../../api/client"; +import type { Observer } from "../observers/types"; +import { fillActivity } from "../stats/transforms"; +import type { ObserverActivity, TelemetryPoint } from "../stats/types"; +import type { AtlasPin, AtlasRange } from "./atlas-storage"; + +export const HOUR = 3_600_000; +export const finite = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value); +export const latest = (values: (number | null)[]) => values.findLast(finite) ?? null; + +export async function loadAtlasObserver(pin: AtlasPin, signal?: AbortSignal): Promise { + let observer: Observer | undefined; + try { observer = await getObserver(pin.id, signal); } + catch (error) { if (!isNotFound(error)) throw error; } + if (pin.publicKey && observer?.publicKey?.toLowerCase() !== pin.publicKey) { + const page = await getNodesPage(undefined, { pubkeyPrefix: pin.publicKey, limit: 2 }, signal); + const node = page.items.find(row => row.publicKey.toLowerCase() === pin.publicKey && row.observerId); + observer = node?.observerId ? await getObserver(node.observerId, signal) : undefined; + } + if (!observer || (pin.publicKey && observer.publicKey?.toLowerCase() !== pin.publicKey) || (!pin.publicKey && observer.id !== pin.id)) { + throw new ApiError(404, "not_found", "Saved observer identity unavailable"); + } + return observer; +} + +// Keep one actual sample per hour. Missing hours remain null; no zero fill or invented readings. +export function observerTelemetrySeries(points: TelemetryPoint[], range: AtlasRange, now: number) { + const hours = range === "3d" ? 72 : 24; + const start = Math.floor(now / HOUR) * HOUR - (hours - 1) * HOUR; + const fields = ["batteryMv", "uptimeSeconds", "noiseFloorDb"] as const; + const series = { batteryMv: Array(hours).fill(null), uptimeSeconds: Array(hours).fill(null), noiseFloorDb: Array(hours).fill(null) }; + for (const point of [...points].sort((a, b) => a.t - b.t)) { + if (!finite(point.t) || point.t < start || point.t > now) continue; + const slot = Math.floor((point.t - start) / HOUR); + for (const field of fields) if (finite(point[field])) series[field][slot] = point[field]; + } + return series; +} + +export function observerActivitySeries(data: ObserverActivity | undefined, range: AtlasRange, now: number) { + if (!data) return []; + const end = finite(data.windowEnd) ? data.windowEnd : now; + const start = end - (range === "3d" ? 72 : 24) * HOUR; + return fillActivity(data.points.filter(p => finite(p.t) && p.t >= start && p.t <= end), HOUR, { start, end }, data); +} diff --git a/src/features/atlas/useCardDrag.ts b/src/features/atlas/useCardDrag.ts index 378779d..e7f2fd6 100644 --- a/src/features/atlas/useCardDrag.ts +++ b/src/features/atlas/useCardDrag.ts @@ -14,18 +14,24 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number) const cards = useRef(new Map()); const [drag, setDrag] = useState(null); const cleanup = useRef<() => void>(() => {}); + const active = useRef(false); useEffect(() => () => cleanup.current(), []); const start = useCallback((index: number, event: PointerEvent) => { - if (event.button !== 0) return; + if (event.button !== 0 || active.current) return; event.preventDefault(); + active.current = true; + const pointerId = event.pointerId; + const handle = event.currentTarget; + handle.setPointerCapture?.(pointerId); 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) => { + if (e.pointerId !== pointerId) return; 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); @@ -35,20 +41,27 @@ export function useCardDrag(count: number, onReorder: (from: number, to: number) setDrag(null); if (commit && current.over !== current.from) onReorder(current.from, current.over); }; - const onUp = () => end(true); + const onUp = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) end(true); }; const onCancel = () => end(false); + const onPointerCancel = (e: globalThis.PointerEvent) => { if (e.pointerId === pointerId) 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("pointercancel", onPointerCancel); window.addEventListener("keydown", onKey); + window.addEventListener("blur", onCancel); + handle.addEventListener("lostpointercapture", onPointerCancel); cleanup.current = () => { window.removeEventListener("pointermove", onMove); window.removeEventListener("pointerup", onUp); - window.removeEventListener("pointercancel", onCancel); + window.removeEventListener("pointercancel", onPointerCancel); window.removeEventListener("keydown", onKey); + window.removeEventListener("blur", onCancel); + handle.removeEventListener("lostpointercapture", onPointerCancel); + if (handle.hasPointerCapture?.(pointerId)) handle.releasePointerCapture(pointerId); + active.current = false; cleanup.current = () => {}; }; }, [count, onReorder]); diff --git a/src/features/atlas/useSavedAtlas.ts b/src/features/atlas/useSavedAtlas.ts index 5e74d36..a3c078f 100644 --- a/src/features/atlas/useSavedAtlas.ts +++ b/src/features/atlas/useSavedAtlas.ts @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from "react"; -import { ATLAS_KEY, parseAtlas, type SavedAtlas } from "./atlas"; +import { ATLAS_KEY, ATLAS_LEGACY_KEY, parseAtlas, type SavedAtlas } from "./atlas"; export function useSavedAtlas() { const [initial] = useState(() => { try { - return { value: parseAtlas(localStorage.getItem(ATLAS_KEY)), failed: false }; + return { value: parseAtlas(localStorage.getItem(ATLAS_KEY) ?? localStorage.getItem(ATLAS_LEGACY_KEY)), failed: false }; } catch { return { value: parseAtlas(null), failed: true }; } diff --git a/src/features/stats/cards.tsx b/src/features/stats/cards.tsx index 7b14d1a..9054e94 100644 --- a/src/features/stats/cards.tsx +++ b/src/features/stats/cards.tsx @@ -32,13 +32,13 @@ const SPARK_W = 120; const SPARK_H = 20; const sparkSpacer =
; -function Sparkline({ values, color, markPeak }: { values: (number | null)[]; color: string; markPeak?: boolean }) { +function Sparkline({ values, color, markPeak, interpolateGaps }: { values: (number | null)[]; color: string; markPeak?: boolean; interpolateGaps?: boolean }) { const known = values.filter((v): v is number => v !== null); - if (values.length < 2 || known.length < 2) return sparkSpacer; + if (!known.length) return sparkSpacer; const max = Math.max(...known); const min = Math.min(...known); const range = max - min || 1; - const x = (i: number) => (i / (values.length - 1)) * SPARK_W; + const x = (i: number) => values.length === 1 ? SPARK_W / 2 : (i / (values.length - 1)) * SPARK_W; const y = (v: number) => SPARK_H - 1 - ((v - min) / range) * (SPARK_H - 2); // A null hour breaks the line, so an outage shows as a gap rather than a straight join. const runs: string[][] = [[]]; @@ -46,12 +46,15 @@ function Sparkline({ values, color, markPeak }: { values: (number | null)[]; col if (v === null) runs.push([]); else runs[runs.length - 1]!.push(`${x(i)},${y(v)}`); }); + const samples = values.flatMap((v, i) => v == null ? [] : [{ v, i }]); const peak = values.indexOf(max); return ( {runs.filter((run) => run.length > 1).map((run) => ( ))} + {runs.filter(run => run.length === 1).map(run => { const [px, py] = run[0]!.split(","); return ; })} + {interpolateGaps && samples.slice(1).map((point, i) => point.i > samples[i]!.i + 1 ? : null)} {markPeak && ( // zero-length round-capped stroke: stays a circle under preserveAspectRatio="none" @@ -91,6 +94,7 @@ export function StatCard({ presence, sublabel, compact, + interpolateGaps, }: { label: string; value: ReactNode; @@ -99,6 +103,7 @@ export function StatCard({ markPeak?: boolean; presence?: boolean[]; sublabel?: ReactNode; + interpolateGaps?: boolean; compact?: boolean; // narrow tiles: value stays text-xl so "-77.2 dBm" never wraps }) { return ( @@ -109,7 +114,7 @@ export function StatCard({
{value}
- {presence ? : spark ? : sparkSpacer} + {presence ? : spark ? : sparkSpacer}
); diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 58dcca3..a8a0578 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -284,7 +284,7 @@ "observations_one": "{{formatted}} obs", "observations_other": "{{formatted}} obs", "hops": "Hops", - "hardware": "Hardware", + "hardware": "Hardware", "power": "Power", "antenna": "Antenna", "powerSource": "Power source", @@ -957,10 +957,10 @@ "perDay": "{{value}}/d" }, "atlas": { - "addNodes": "Add nodes", + "addNodes": "Add nodes or observers", "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.", + "searchHelp": "Search all regions by node name, public-key prefix (hex, at least {{min}} characters), or observer name. Up to {{limit}} cards.", "searchTooShort": "Type at least {{min}} characters.", "searchNotHex": "A public key prefix is hex only (0-9, a-f).", "savedCount_one": "{{count}} / {{limit}} saved", @@ -974,8 +974,8 @@ "saved": "Saved", "addName": "Add {{name}}", "savedName": "{{name}} is saved", - "emptyTitle": "No nodes saved", - "emptyDescription": "Search above to add up to {{limit}} nodes", + "emptyTitle": "No cards saved", + "emptyDescription": "Search above to add up to {{limit}} nodes or observers", "range": "Activity window", "ranges": { "24h": "24h", @@ -995,7 +995,18 @@ "heardUnavailable": "Packet details are unavailable or expired.", "openPacket": "Open packet", "dragToReorder": "Drag to reorder", - "remove": "Remove" + "remove": "Remove", + "observerCard": "Observer", + "searchObservers": "Observers", + "observerSearch": "Find an observer", + "observerMissing": "This saved observer is unavailable. Its identity could not be verified.", + "observerPartial": "Some observer data could not be refreshed. Available samples are retained.", + "observerPartialCoverage": "Partial history", + "observerReports": "Packets heard", + "observerSnr": "Latest hourly SNR", + "observerRssi": "Latest hourly RSSI", + "observerHistoryHelp": "Solid lines show reported samples. Dashed lines connect across missing hours; they are visual estimates, not measurements. Uptime gaps remain open. Device readings are the latest available status. Packet totals cover the selected window; unread hours remain unknown.", + "observerAddFailed": "Could not add this observer. Try again." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 086a497..9e165a8 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -958,10 +958,10 @@ "perDay": "{{value}}/j" }, "atlas": { - "addNodes": "Ajouter des nœuds", + "addNodes": "Ajouter des nœuds ou observateurs", "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.", + "searchHelp": "Recherchez dans toutes les régions par nom de nœud, préfixe de clé publique (hexadécimal, au moins {{min}} caractères) ou nom d’observateur. Jusqu’à {{limit}} cartes.", "searchTooShort": "Saisissez au moins {{min}} caractères.", "searchNotHex": "Un préfixe de clé publique est en hexadécimal (0-9, a-f).", "savedCount_one": "{{count}} / {{limit}} enregistré", @@ -976,8 +976,8 @@ "saved": "Enregistré", "addName": "Ajouter {{name}}", "savedName": "{{name}} est enregistré", - "emptyTitle": "Aucun nœud enregistré", - "emptyDescription": "Cherchez ci-dessus pour ajouter jusqu’à {{limit}} nœuds", + "emptyTitle": "Aucune carte enregistrée", + "emptyDescription": "Recherchez ci-dessus pour ajouter jusqu’à {{limit}} nœuds ou observateurs", "range": "Période d’activité", "ranges": { "24h": "24 h", @@ -998,7 +998,18 @@ "heardUnavailable": "Détails du paquet indisponibles ou expirés.", "openPacket": "Ouvrir le paquet", "dragToReorder": "Glisser pour réordonner", - "remove": "Retirer" + "remove": "Retirer", + "observerCard": "Observateur", + "searchObservers": "Observateurs", + "observerSearch": "Trouver un observateur", + "observerMissing": "Cet observateur enregistré est indisponible. Son identité n’a pas pu être vérifiée.", + "observerPartial": "Certaines données n’ont pas pu être actualisées. Les mesures disponibles sont conservées.", + "observerPartialCoverage": "Historique partiel", + "observerReports": "Paquets reçus", + "observerSnr": "Dernier SNR horaire", + "observerRssi": "Dernier RSSI horaire", + "observerHistoryHelp": "Les lignes pleines représentent les mesures. Les pointillés relient les heures manquantes : ce sont des estimations visuelles, pas des mesures. Les interruptions de disponibilité restent visibles. Les valeurs de l’appareil proviennent du dernier état disponible. Les totaux concernent la période sélectionnée; les heures non lues restent inconnues.", + "observerAddFailed": "Impossible d’ajouter cet observateur. Réessayez." } } } diff --git a/tests/features/atlas/AtlasObserverCard.test.tsx b/tests/features/atlas/AtlasObserverCard.test.tsx new file mode 100644 index 0000000..c124c36 --- /dev/null +++ b/tests/features/atlas/AtlasObserverCard.test.tsx @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as api from "../../../src/api/client"; +import { MyAtlasPage } from "../../../src/features/atlas/MyAtlasPage"; +import { ATLAS_KEY, ATLAS_LEGACY_KEY } from "../../../src/features/atlas/atlas-storage"; +import type { Observer, ObserverDirectoryPage } from "../../../src/features/observers/types"; +import type { ObserverActivity, ObserverTelemetry } from "../../../src/features/stats/types"; + +vi.mock("../../../src/api/client", async original => ({ ...(await original()), getObserver: vi.fn(), getObserverActivity: vi.fn(), getObserverTelemetry: vi.fn(), getObserverDirectoryPage: vi.fn(), getNodesPage: vi.fn(), getNode: vi.fn(), getNodeObservations: vi.fn() })); +const pin = { kind: "observer", id: "11111111-1111-4111-8111-111111111111", publicKey: "ab".repeat(32), name: "Home observer" }; +const observer = { ...pin, displayName: pin.name, status: "online", lastStatusAt: Date.now(), lastSeen: Date.now(), batteryLevel: 4.12, uptimeSeconds: 3600 } as Observer; +const clients: QueryClient[] = []; +const view = vi.fn(); +function mount(active = true) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); clients.push(client); + return render(); +} +beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); + vi.mocked(api.getObserver).mockResolvedValue(observer); + vi.mocked(api.getObserverActivity).mockResolvedValue({ range: "24h", interval: "1h", windowEnd: Date.now(), points: [{ t: Date.now(), observations: 8, snrAvg: 0, rssiAvg: -95, airtimeMs: null, snrMin: 0 }] } as ObserverActivity); + vi.mocked(api.getObserverTelemetry).mockResolvedValue({ range: "24h", interval: "1h", points: [] } as ObserverTelemetry); + vi.mocked(api.getObserverDirectoryPage).mockResolvedValue({ items: [observer], hasMore: false } as unknown as ObserverDirectoryPage); +}); +afterEach(() => { clients.splice(0).forEach(c => c.clear()); }); +it("restores an observer card, shows available history, changes range and opens observer detail", async () => { + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, range: "24h", cards: [pin] })); + mount(); + expect(await screen.findByText("4.12V")).toBeInTheDocument(); + expect(await screen.findByText("0 dB")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: pin.name })); expect(view).toHaveBeenCalledWith(pin.id); + fireEvent.click(screen.getByRole("button", { name: "3d" })); + await waitFor(() => expect(api.getObserverActivity).toHaveBeenCalledWith(pin.id, "72h", "1h", undefined, expect.any(AbortSignal))); + expect(api.getObserverTelemetry).toHaveBeenCalledWith(pin.id, "72h", "1h", undefined, expect.any(AbortSignal)); +}); +it("does not turn missing device data or an unavailable history endpoint into zeros", async () => { + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, cards: [pin] })); + vi.mocked(api.getObserver).mockResolvedValue({ ...observer, batteryLevel: undefined, uptimeSeconds: undefined }); + vi.mocked(api.getObserverTelemetry).mockRejectedValue(new api.ApiError(503, "busy", "unavailable")); + mount(); await screen.findByRole("alert"); + expect(screen.queryByText("0.00V")).not.toBeInTheDocument(); + expect(screen.getByText("0 dB")).toBeInTheDocument(); + expect(screen.getAllByText("—").length).toBeGreaterThanOrEqual(3); +}); +it("queries nothing for inactive saved observers", () => { + localStorage.setItem(ATLAS_KEY, JSON.stringify({ version: 2, cards: [pin] })); mount(false); + expect(api.getObserver).not.toHaveBeenCalled(); expect(api.getObserverActivity).not.toHaveBeenCalled(); expect(api.getObserverTelemetry).not.toHaveBeenCalled(); +}); +it("adds an observer by name without rewriting the legacy node store", async () => { + const original = JSON.stringify({ version: 1, range: "24h", nodes: [] }); localStorage.setItem(ATLAS_LEGACY_KEY, original); + mount(); + fireEvent.click(screen.getByRole("button", { name: /Name/ })); + fireEvent.click(screen.getByRole("button", { name: "Observers", exact: true })); + fireEvent.change(screen.getByRole("textbox", { name: "Find an observer" }), { target: { value: "Home" } }); + fireEvent.click(await screen.findByRole("button", { name: "Add Home observer" })); + await screen.findByText("4.12V"); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards).toEqual([pin]); + expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(original); + fireEvent.click(screen.getByRole("button", { name: "Remove" })); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards).toEqual([])); +}); + +it("preserves legacy nodes while reordering a mixed dashboard", async () => { + const node = { id: "22222222-2222-4222-8222-222222222222", publicKey: "cd".repeat(32), name: "Repeater", nodeTypeName: "REPEATER", knownNeighborCount: 0, iatas: [] }; + const original = JSON.stringify({ version: 1, range: "24h", nodes: [node] }); + localStorage.setItem(ATLAS_LEGACY_KEY, original); + vi.mocked(api.getNode).mockResolvedValue(node as never); + vi.mocked(api.getNodeObservations).mockResolvedValue({ items: [], hasMore: false }); + mount(); await screen.findByRole("button", { name: "Repeater", exact: true }); + fireEvent.click(screen.getByRole("button", { name: /Name/ })); + fireEvent.click(screen.getByRole("button", { name: "Observers", exact: true })); + fireEvent.change(screen.getByRole("textbox", { name: "Find an observer" }), { target: { value: "Home" } }); + fireEvent.click(await screen.findByRole("button", { name: "Add Home observer" })); + await screen.findByText("4.12V"); + fireEvent.keyDown(screen.getAllByRole("button", { name: "Drag to reorder" })[1]!, { key: "ArrowLeft" }); + const saved = JSON.parse(localStorage.getItem(ATLAS_KEY)!); + expect(saved.cards.map((p: { kind: string }) => p.kind)).toEqual(["observer", "node"]); + expect(saved.cards[1].publicKey).toBe(node.publicKey); + expect(localStorage.getItem(ATLAS_LEGACY_KEY)).toBe(original); +}); diff --git a/tests/features/atlas/MyAtlasPage.test.tsx b/tests/features/atlas/MyAtlasPage.test.tsx index 6496a9b..a4d85af 100644 --- a/tests/features/atlas/MyAtlasPage.test.tsx +++ b/tests/features/atlas/MyAtlasPage.test.tsx @@ -69,7 +69,7 @@ describe("My Atlas", () => { save(); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - const bar = screen.getByRole("toolbar", { name: "Add nodes" }); + const bar = screen.getByRole("toolbar", { name: "Add nodes or observers" }); expect(within(bar).getByRole("textbox", { name: "Find a node" })).toBeInTheDocument(); expect(within(bar).getByText("1 / 12 saved")).toHaveClass("rounded-sm"); expect(within(bar).getByRole("button", { name: "3d" })).toBeInTheDocument(); @@ -84,7 +84,7 @@ describe("My Atlas", () => { save(); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - const bar = screen.getByRole("toolbar", { name: "Add nodes" }); + const bar = screen.getByRole("toolbar", { name: "Add nodes or observers" }); expect(bar).not.toHaveClass("flex-wrap"); expect(within(bar).getByText("1/12")).toHaveClass("rounded-sm"); expect(within(bar).queryByText("1 / 12 saved")).not.toBeInTheDocument(); @@ -94,7 +94,7 @@ describe("My Atlas", () => { it("shows the empty state and queries nothing with no pins", () => { mount(); - expect(screen.getByText("No nodes saved")).toBeInTheDocument(); + expect(screen.getByText("No cards saved")).toBeInTheDocument(); expect(api.getNode).not.toHaveBeenCalled(); expect(api.getNodesPage).not.toHaveBeenCalled(); }); @@ -107,7 +107,7 @@ describe("My Atlas", () => { expect(await screen.findByRole("button", { name: "Renamed repeater" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Renamed repeater is saved" })).toBeDisabled(); expect(screen.getByText("Showing the first 8 matches; refine the search for more.")).toBeInTheDocument(); - expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes).toHaveLength(1); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards).toHaveLength(1); expect(api.getNodesPage).toHaveBeenCalledWith(undefined, { name: "repeater", limit: 8 }, expect.any(AbortSignal)); view.unmount(); mount(); @@ -175,7 +175,7 @@ describe("My Atlas", () => { expect(within(first).getByRole("button", { name: "Heard by" })).toHaveAttribute("aria-pressed", "false"); expect(within(next).getByRole("button", { name: "Analytics" })).toHaveAttribute("aria-pressed", "true"); fireEvent.keyDown(within(first).getByRole("button", { name: "Drag to reorder" }), { key: "ArrowRight" }); - expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].publicKey).toBe(second.publicKey); + expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards[0].publicKey).toBe(second.publicKey); fireEvent.click(screen.getByRole("button", { name: "3d" })); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).range).toBe("3d"); @@ -208,7 +208,7 @@ describe("My Atlas", () => { await screen.findByRole("button", { name: "Renamed repeater" }); expect(screen.getByRole("button", { name: "3d" })).toHaveAttribute("aria-pressed", "true"); act(() => window.dispatchEvent(new StorageEvent("storage", { key: null, newValue: null }))); - expect(screen.getByText("No nodes saved")).toBeInTheDocument(); + expect(screen.getByText("No cards saved")).toBeInTheDocument(); }); it("does not query a retained but inactive page", () => { @@ -247,7 +247,7 @@ describe("identity recovery", () => { save(); mount(); await screen.findByRole("button", { name: "Renamed repeater" }); - await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).nodes[0].id).toBe(second.id)); + await waitFor(() => expect(JSON.parse(localStorage.getItem(ATLAS_KEY)!).cards[0].id).toBe(second.id)); }); it("does not adopt a mismatched identity or hide transport failures with broad searches", async () => { vi.mocked(api.getNode).mockResolvedValueOnce({ ...node, publicKey: second.publicKey }); diff --git a/tests/features/atlas/atlas.test.ts b/tests/features/atlas/atlas.test.ts index e82e779..27d5db6 100644 --- a/tests/features/atlas/atlas.test.ts +++ b/tests/features/atlas/atlas.test.ts @@ -8,13 +8,13 @@ const report = (id: number, hours: number, extra: Partial = {}) describe("saved Atlas", () => { it("restores valid ordered full identities, normalizes keys and removes duplicates", () => { - expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 1, range: "3d", nodes: [{ ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }); + expect(parseAtlas(JSON.stringify({ version: 1, range: "3d", nodes: [pin, { ...pin, publicKey: pin.publicKey.toLowerCase() }, { ...pin, publicKey: "cd".repeat(32) }] }))).toEqual({ version: 2, range: "3d", cards: [{ ...pin, kind: "node", publicKey: pin.publicKey.toLowerCase() }, { ...pin, kind: "node", publicKey: "cd".repeat(32) }] }); }); it("tolerates unavailable, corrupt and future storage, rejects partial keys and caps the collection", () => { - for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).nodes).toEqual([]); + for (const value of [null, "{", "[]", '{"version":2}', '{"version":1,"nodes":null}']) expect(parseAtlas(value).cards).toEqual([]); const nodes = [{ ...pin, publicKey: "abcd" }, { ...pin, id: "../unsafe" }, ...Array.from({ length: 20 }, (_, i) => ({ ...pin, publicKey: i.toString(16).padStart(64, "0") }))]; const result = parseAtlas(JSON.stringify({ version: 1, range: "30d", nodes })); - expect(result.nodes).toHaveLength(ATLAS_LIMIT); + expect(result.cards).toHaveLength(ATLAS_LIMIT); expect(result.range).toBe("24h"); }); }); diff --git a/tests/features/atlas/observer.test.ts b/tests/features/atlas/observer.test.ts new file mode 100644 index 0000000..251fca1 --- /dev/null +++ b/tests/features/atlas/observer.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as api from "../../../src/api/client"; +import { ATLAS_KEY, ATLAS_LEGACY_KEY, atlasPinKey, parseAtlas } from "../../../src/features/atlas/atlas-storage"; +import { HOUR, loadAtlasObserver, observerActivitySeries, observerTelemetrySeries } from "../../../src/features/atlas/observer"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity, TelemetryPoint } from "../../../src/features/stats/types"; + +const id = "11111111-1111-4111-8111-111111111111"; +const key = "ab".repeat(32); +const pin = { id, publicKey: key, name: "Home", kind: "observer" as const }; +afterEach(() => vi.restoreAllMocks()); + +describe("mixed saved Atlas", () => { + it("migrates v1 node order into a separate v2 key, including pending preview identities", () => { + expect(ATLAS_KEY).not.toBe(ATLAS_LEGACY_KEY); + const original = { version: 1, range: "3d", nodes: [{ ...pin, kind: undefined }, { id: "", name: "Pending", publicKey: "cd".repeat(32) }] }; + const saved = parseAtlas(JSON.stringify({ ...original, nodes: [null, ...original.nodes, 7] })); + expect(saved).toEqual({ version: 2, range: "3d", cards: original.nodes.map(p => ({ ...p, kind: "node" })) }); + }); + it("allows node and observer views of one identity but deduplicates each kind", () => { + const saved = parseAtlas(JSON.stringify({ version: 2, cards: [{ ...pin, kind: "node" }, pin, { ...pin, publicKey: key.toUpperCase() }, { ...pin, kind: "other" }, { ...pin, id: "" }] })); + expect(saved.cards).toHaveLength(2); + expect(atlasPinKey(saved.cards[0]!)).not.toBe(atlasPinKey(saved.cards[1]!)); + }); + it("supports UUID-only observers, but rejects partial keys and nil observer IDs", () => { + const saved = parseAtlas(JSON.stringify({ version: 2, cards: [{ ...pin, publicKey: "" }, { ...pin, publicKey: "ab" }, { ...pin, id: "00000000-0000-0000-0000-000000000000" }] })); + expect(saved.cards).toEqual([{ ...pin, publicKey: "" }]); + }); +}); + +describe("observer identity and history", () => { + it("does not adopt a different observer when a saved UUID changes identity", async () => { + vi.spyOn(api, "getObserver").mockResolvedValue({ id, publicKey: "ff".repeat(32) } as Observer); + vi.spyOn(api, "getNodesPage").mockResolvedValue({ items: [], hasMore: false }); + await expect(loadAtlasObserver(pin)).rejects.toMatchObject({ status: 404 }); + }); + it("relocates only through the same full public key and validates the final observer", async () => { + const next = "22222222-2222-4222-8222-222222222222"; + vi.spyOn(api, "getObserver").mockRejectedValueOnce(new api.ApiError(404, "not_found", "gone")).mockResolvedValueOnce({ id: next, publicKey: key } as Observer); + vi.spyOn(api, "getNodesPage").mockResolvedValue({ items: [{ publicKey: key, observerId: next }] as never, hasMore: false }); + expect((await loadAtlasObserver(pin)).id).toBe(next); + }); + it("keeps zero, missing hours, and uptime resets distinct and excludes future samples", () => { + const now = 30 * HOUR; + const point = (t: number, value: number | null) => ({ t, batteryMv: value, noiseFloorDb: value, uptimeSeconds: value } as TelemetryPoint); + const series = observerTelemetrySeries([point(now - 2 * HOUR, 4000), point(now, 0), point(now + HOUR, 9999), point(now - 1, null)], "24h", now); + expect(series.batteryMv.slice(-3)).toEqual([4000, null, 0]); + expect(series.uptimeSeconds.slice(-3)).toEqual([4000, null, 0]); + expect(observerTelemetrySeries([], "3d", now).batteryMv).toHaveLength(72); + }); + it("retains unread activity hours as unknown rather than a measured zero", () => { + const end = 30 * HOUR; + const rows = observerActivitySeries({ windowEnd: end, range: "24h", interval: "1h", points: [], rawFrom: end - HOUR } as unknown as ObserverActivity, "24h", end); + expect(rows.find(p => p.t === end - 2 * HOUR)?.observations).toBeNull(); + expect(rows.find(p => p.t === end)?.observations).toBe(0); + }); +}); diff --git a/tests/features/atlas/useCardDrag.test.tsx b/tests/features/atlas/useCardDrag.test.tsx index 97b0a0a..012dbf1 100644 --- a/tests/features/atlas/useCardDrag.test.tsx +++ b/tests/features/atlas/useCardDrag.test.tsx @@ -25,15 +25,54 @@ function mount() { ({ 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")); +const move = (x: number, y = 50) => pointer("pointermove", window, 0, x, y); +const drop = () => pointer("pointerup", window, 0); +const pointer = (type: string, target: Element | Window, id: number, x = 50, y = 50) => { + const event = new MouseEvent(type, { bubbles: true, button: 0, clientX: x, clientY: y }); + Object.defineProperty(event, "pointerId", { value: id }); + fireEvent(target, event); +}; beforeEach(() => vi.clearAllMocks()); describe("useCardDrag", () => { + it("ignores movement and release from another pointer", () => { + mount(); + pointer("pointerdown", screen.getByLabelText("handle 0"), 1); + pointer("pointermove", window, 2, 250); + pointer("pointerup", window, 2); + expect(onReorder).not.toHaveBeenCalled(); + pointer("pointermove", window, 1, 150); + pointer("pointerup", window, 1); + expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 1); + }); + + it("ignores a second drag start and removes the first drag's listeners on drop", () => { + mount(); + pointer("pointerdown", screen.getByLabelText("handle 0"), 1); + pointer("pointerdown", screen.getByLabelText("handle 1"), 2, 150); + pointer("pointermove", window, 1, 250); + pointer("pointerup", window, 1); + expect(onReorder).toHaveBeenCalledExactlyOnceWith(0, 2); + pointer("pointermove", window, 2, 150); + pointer("pointerup", window, 2); + expect(onReorder).toHaveBeenCalledTimes(1); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + }); + + it("cancels when the window loses focus", () => { + mount(); + pointer("pointerdown", screen.getByLabelText("handle 0"), 1); + pointer("pointermove", window, 1, 250); + fireEvent.blur(window); + pointer("pointerup", window, 1); + expect(onReorder).not.toHaveBeenCalled(); + expect(screen.getByTestId("card-0").style.transform).toBe(""); + }); + 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 }); + pointer("pointerdown", screen.getByLabelText("handle 0"), 0); move(250); expect(screen.getByTestId("card-0")).toHaveStyle({ transform: "translate(200px, 0px)" }); expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(-100px, 0px)" }); @@ -45,7 +84,7 @@ describe("useCardDrag", () => { it("slides cards forward when dragging towards the start", () => { mount(); - fireEvent.pointerDown(screen.getByLabelText("handle 2"), { clientX: 250, clientY: 50 }); + pointer("pointerdown", screen.getByLabelText("handle 2"), 0, 250); move(120); expect(screen.getByTestId("card-1")).toHaveStyle({ transform: "translate(100px, 0px)" }); expect(screen.getByTestId("card-0").style.transform).toBe(""); @@ -55,7 +94,7 @@ describe("useCardDrag", () => { 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 }); + pointer("pointerdown", screen.getByLabelText("handle 1"), 0, 150); move(160); move(160, 900); drop(); @@ -64,7 +103,7 @@ describe("useCardDrag", () => { it("cancels on Escape", () => { mount(); - fireEvent.pointerDown(screen.getByLabelText("handle 0"), { clientX: 50, clientY: 50 }); + pointer("pointerdown", screen.getByLabelText("handle 0"), 0); move(250); fireEvent.keyDown(window, { key: "Escape" }); expect(screen.getByTestId("card-0").style.transform).toBe(""); diff --git a/tests/features/stats/cards.test.tsx b/tests/features/stats/cards.test.tsx index cf5be95..5ca6433 100644 --- a/tests/features/stats/cards.test.tsx +++ b/tests/features/stats/cards.test.tsx @@ -31,3 +31,11 @@ it("draws a presence strip with one bar per run of reporting hours", () => { const bars = [...container.querySelectorAll("rect")]; expect(bars.map((b) => [b.getAttribute("x"), b.getAttribute("width")])).toEqual([["0", "48"], ["72", "24"]]); }); + + +it("draws isolated samples and dashed gap connectors without filling missing readings", () => { + const { container } = render(); + expect(container.querySelectorAll("[data-sample]")).toHaveLength(2); + expect(container.querySelectorAll("[data-gap]")).toHaveLength(1); + expect(container.querySelector("[data-gap]")).toHaveAttribute("stroke-dasharray", "3 3"); +}); From 5786dd89fe8b2f03acc8b8a8036b5e7d0aa6ff6b Mon Sep 17 00:00:00 2001 From: n30nex Date: Wed, 14 Oct 2026 10:11:59 -0400 Subject: [PATCH 2/2] Contain Atlas accessibility text and narrow card actions --- package-lock.json | 4 ++-- package.json | 2 +- src/features/atlas/AtlasCard.tsx | 2 +- src/features/atlas/MyAtlasPage.tsx | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3aa8c61..9df24e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "beacon-web", - "version": "2.1.0-dev1.3", + "version": "2.1.0-dev1.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "beacon-web", - "version": "2.1.0-dev1.3", + "version": "2.1.0-dev1.4", "license": "AGPL-3.0-or-later", "dependencies": { "@nazka/map-gl-js-spiderfy": "^2.0.0", diff --git a/package.json b/package.json index 14079fb..e8f1223 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "beacon-web", "private": true, - "version": "2.1.0-dev1.3", + "version": "2.1.0-dev1.4", "license": "AGPL-3.0-or-later", "type": "module", "scripts": { diff --git a/src/features/atlas/AtlasCard.tsx b/src/features/atlas/AtlasCard.tsx index 54e9fb2..2feacb7 100644 --- a/src/features/atlas/AtlasCard.tsx +++ b/src/features/atlas/AtlasCard.tsx @@ -146,7 +146,7 @@ export function AtlasCard({ ); const actions = ( -
+
{views.length > 1 && (
-
+
{typed.length > 0 && (