diff --git a/src/App.tsx b/src/App.tsx index 28fcb79..45a155b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,7 +23,10 @@ import { PacketPathMapModal } from "./features/map/PacketPathMapModal"; import { NodeTable } from "./features/nodes/NodeTable"; import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel"; import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay"; -import { ObserverTable } from "./features/observers/ObserverTable"; +import { ObserverPage } from "./features/observers/ObserverPage"; +import { ObserverDetailPanel } from "./features/observers/ObserverDetailPanel"; +import { observerDestination } from "./features/observers/observer-navigation"; +import { ModalOverlay } from "./components/ModalOverlay"; import { RouteTable } from "./features/routes/RouteTable"; import { TraceList } from "./features/traces/TraceList"; import { ChannelList } from "./features/channels/ChannelList"; @@ -149,13 +152,15 @@ function TabLoading({ tab }: { tab: string }) { } function AppInner() { + const { t } = useTranslation(); const [searchParams, setSearchParams] = useSearchParams(); const isMobile = useIsMobile(); // The URL is the single source of truth for the active tab — back/forward just work, and an // unknown ?tab value falls back to Packets instead of rendering a blank pane. // "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working. const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab"); - const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); + const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer"; + const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets"); // Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection). const [initialSelection] = useState(() => computeInitialSelection(searchParams)); @@ -165,7 +170,10 @@ function AppInner() { const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(() => searchParams.get("node")); // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts - const [selectedObserverId, setSelectedObserverId] = useState(() => searchParams.get("observer")); + const [quickObserverId, setQuickObserverId] = useState(null); + useEffect(() => { + if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true }); + }, [legacyObserver, searchParams, setSearchParams]); // node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop) const [overlayNodeId, setOverlayNodeId] = useState(null); // packet analyzer shown as a modal over the node panel (clicking a node's observation row) @@ -210,15 +218,16 @@ function AppInner() { if (isMobile) { setSelectedObservationId(null); setSelectedNodeId(null); - setSelectedObserverId(null); + setQuickObserverId(null); } setSearchParams((prev) => { const next = new URLSearchParams(prev); next.set("tab", tab); // the analyzer is URL-backed, so its mobile close lives here rather than above if (isMobile) next.delete("analyze"); + if (tab !== "Observers") next.delete("observer"); // stats sub-state shouldn't haunt the URL on other tabs - if (tab !== "Analytics") { + if (tab !== "Analytics" && tab !== "Observers") { next.delete("statsTab"); next.delete("observerId"); next.delete("range"); @@ -231,14 +240,14 @@ function AppInner() { setSelectedNodeId(null); setOverlayNodeId(null); setOverlayPacketHash(null); - setSelectedObserverId(null); + setQuickObserverId(null); setPathMapDetail(null); }, []); // Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet // analyzer's ?analyze cleanup). Selecting a different node/observer doesn't touch the URL — the panel's // Copy Link button rebuilds a fresh link on demand. - const dropSelectionParam = useCallback((key: "node" | "observer") => { + const dropSelectionParam = useCallback((key: "node") => { setSearchParams((prev) => { const next = new URLSearchParams(prev); next.delete(key); @@ -251,22 +260,15 @@ function AppInner() { dropSelectionParam("node"); }, [dropSelectionParam]); - const handleSelectObserver = useCallback((id: string | null) => { - setSelectedObserverId(id); - if (id === null) dropSelectionParam("observer"); - }, [dropSelectionParam]); - // Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected). const handleViewObserverStats = useCallback( (id: string) => { setOverlayNodeId(null); setOverlayPacketHash(null); + setQuickObserverId(null); setSearchParams((prev) => { const next = new URLSearchParams(prev); - next.set("tab", "Analytics"); - next.set("statsTab", "observer"); - next.set("observerId", id); - return next; + return observerDestination(next, id); }); }, [setSearchParams], @@ -291,7 +293,7 @@ function AppInner() { /> ), Nodes: , - Observers: , + Observers: , Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses @@ -335,8 +337,7 @@ function AppInner() { nodeId={selectedNodeId} onClose={handleCloseNode} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewNode={setSelectedNodeId} onAnalyzePacket={setOverlayPacketHash} @@ -345,10 +346,10 @@ function AppInner() { {overlayNodeId && ( setOverlayNodeId(null)} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewNode={setOverlayNodeId} /> @@ -359,13 +360,15 @@ function AppInner() { loading={overlayPacketLoading} onClose={() => setOverlayPacketHash(null)} onViewObserver={(observerId) => { - handleTabChange("Observers"); - setSelectedObserverId(observerId); + setQuickObserverId(observerId); }} onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }} - inactive={!!pathMapDetail} + inactive={!!pathMapDetail || !!quickObserverId} /> )} + {quickObserverId && setQuickObserverId(null)}> + setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={hash => { setQuickObserverId(null); setOverlayPacketHash(hash); }} /> + } {pathMapDetail && ( { return request(`/traces/${tag}`); } -export function getObserver(observerId: string): Promise { - return request(`/observers/${observerId}`); +export async function getObserver(observerId: string): Promise { + const observer = await request(`/observers/${observerId}`); + // Older servers marshal the stored JSON byte slice as base64 instead of an object. + let metadata: unknown = observer.statusMetadata; + if (typeof metadata === "string") { + try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); } + catch { metadata = undefined; } + } + return { ...observer, statusMetadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata as Record : undefined }; } export function getObserverAdverts( @@ -368,8 +375,8 @@ export function getObserverTelemetry( return request(`/observers/${observerId}/telemetry`, { range, interval, afterId }); } -export function getObserverActivity(observerId: string, range: string, interval: string): Promise { - return request(`/observers/${observerId}/activity`, { range, interval }); +export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise { + return request(`/observers/${observerId}/activity`, { range, interval, until }); } // Lets a caller hide a feature the server doesn't have rather than show it as failed. diff --git a/src/features/nodes/NodeDetailOverlay.tsx b/src/features/nodes/NodeDetailOverlay.tsx index 514c949..30bad75 100644 --- a/src/features/nodes/NodeDetailOverlay.tsx +++ b/src/features/nodes/NodeDetailOverlay.tsx @@ -4,22 +4,23 @@ import { ModalOverlay } from "../../components/ModalOverlay"; // Node detail shown as a modal over the packet analyzer: the panel sits where the packet drawer is // (right side) and the rest dims, so a user can peek at a path hop's node and close back to the packet. -export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode }: { +export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode, inactive = false }: { nodeId: string; + inactive?: boolean; onClose: () => void; onViewObserver: (observerId: string) => void; onViewNode?: (nodeId: string) => void; }) { useEffect(() => { function onKey(e: KeyboardEvent) { - if (e.key === "Escape") onClose(); + if (!inactive && e.key === "Escape") onClose(); } window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [onClose]); + }, [onClose, inactive]); return ( - + ); diff --git a/src/features/observers/ObserverComparison.tsx b/src/features/observers/ObserverComparison.tsx new file mode 100644 index 0000000..7d27770 --- /dev/null +++ b/src/features/observers/ObserverComparison.tsx @@ -0,0 +1,97 @@ +import { useMemo } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserverComparison } from "../../api/client"; +import { formatBattery, formatUptime } from "../../lib/formatters"; +import { ObserverPicker } from "./ObserverPicker"; +import { Card, ChartCard } from "../stats/cards"; +import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry"; +import { useChartColors } from "../stats/chartTheme"; +import { heardOption } from "../stats/chartOptions"; +import { fillActivity, intervalToMs } from "../stats/transforms"; +import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types"; +import type { Observer } from "./types"; + +const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i; +function statusNoise(observer: Observer) { + const stats = observer.statusMetadata?.stats; + return stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? `${stats.noise_floor} dBm` : "—"; +} + +export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: { + observerA: Observer; activityA?: ObserverActivity; range: StatsRange; observerBId: string; until: number | null; + onSelect: (id: string) => void; onRefresh: () => number; +}) { + const { t, i18n } = useTranslation(); const colors = useChartColors(); + const valid = until != null && uuid.test(observerA.id) && uuid.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase(); + const b = useObserver(valid ? observerBId : null); + const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined); + const dataB = activityB.isError ? undefined : activityB.data; + const intervalMs = intervalToMs(activityParamsFor(range).interval)!; + const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs; + const start = end - RANGE_MS[range]; + const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs); + const overlap = useQuery({ + queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end], + queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal), + enabled: valid && aligned && !!b.data && !b.isError, + staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false, + }); + const option = useMemo(() => { + const window = { start, end }; + const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window) : []; + const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window) : []; + return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 }, + legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } }, + series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({ + name: name as string, type: "line" as const, symbol: "none", connectNulls: false, + data: (points as typeof aPoints).map(p => [p.t, p.observations]), + lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string }, + })), + }; + }, [activityA, dataB, aligned, intervalMs, start, end, colors]); + const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); + const groups = overlap.data && !overlap.isError ? [ + { label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary }, + { label: t("observerCompare.both"), count: overlap.data.both, color: colors.green }, + { label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary }, + ] : []; + const refresh = () => { + const nextUntil = onRefresh(); + if (valid) { + void b.refetch(); + if (nextUntil === until) { + void activityB.refetch(); + if (aligned) void overlap.refetch(); + } + } + }; + const rows = aligned && b.data ? [ + [t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)], + [t("observerPage.lastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`], + [t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)], + [t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)], + [t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)], + [t("observerPage.noise"), statusNoise(observerA), statusNoise(b.data)], + ] : []; + return
+

{t("observerCompare.title")}

+
+

A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}}

+ + {until == null ?

{t("observerCompare.invalidTime")}

: !valid ?

{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}

: b.isError || activityB.isError ?

{t("observerPage.loadFailed")}

: !activityA || activityB.isPending || b.isPending ?

{t("common.loading")}

: !aligned ?

{t("observerCompare.unavailable")}

: <> +

{t("observerPage.window")}: {date(start)} – {date(end)}. {t("observerCompare.currentNote")}

+
{rows.map(([label, a, bValue]) => )}
{t("observerCompare.metric")}AB
{label}{a}{bValue}
+ + +

{t("observerCompare.retention")}

+ {overlap.isError ?

{t("common.loadFailed")}

: !overlap.data ?

{t("common.loading")}

: <> +

{t("observerCompare.total", { count: overlap.data.totalPackets })}

+ {overlap.data.totalPackets === 0 ?

{t("observerCompare.empty")}

:
{groups.map(g =>
)}
} +
{groups.map(g =>
{g.label}
{g.count.toLocaleString(i18n.resolvedLanguage)}
)}
+

{t("observerCompare.definition")}

+ } + + } +
; +} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx index 1dd9c43..afe0f82 100644 --- a/src/features/observers/ObserverDetailPanel.tsx +++ b/src/features/observers/ObserverDetailPanel.tsx @@ -1,3 +1,4 @@ +import { useTranslation } from "react-i18next"; import type { Observer, AdvertObservation } from "./types"; import { useQuery } from "@tanstack/react-query"; import { getObserver, getObserverAdverts } from "../../api/client"; @@ -106,6 +107,7 @@ interface ObserverDetailPanelProps { } export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onViewStats }: ObserverDetailPanelProps) { + const { t } = useTranslation(); const { data: observer, isLoading } = useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId), @@ -155,7 +157,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi
- +
{observer.observerType && {observer.observerType}} @@ -173,7 +175,7 @@ export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onVi - Statistics + {t("observerPage.open")} )} diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx new file mode 100644 index 0000000..9a449f5 --- /dev/null +++ b/src/features/observers/ObserverPage.tsx @@ -0,0 +1,72 @@ +import { lazy, Suspense, useState } from "react"; +import { useSearchParams } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { CopyButton } from "../../components/CopyButton"; +import { useTick } from "../../hooks/useTick"; +import { ObserverTable } from "./ObserverTable"; +import { observerDestination, observerRange } from "./observer-navigation"; +import type { WsManager } from "../../api/ws-manager"; +const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); + +export function ObserverPage({ wsManager }: { wsManager: WsManager }) { + const [params, setParams] = useSearchParams(); + const { t } = useTranslation(); + const now = useTick(60_000); + const id = params.get("observer"); + const [directoryVisited, setDirectoryVisited] = useState(!id); + const [actionTime, setActionTime] = useState(() => Date.now()); + const hourAt = (time: number) => Math.floor(time / 3_600_000) * 3_600_000; + const comparing = params.has("compareWith"); + const anchor = params.has("compareUntil") ? Number(params.get("compareUntil")) : hourAt(actionTime); + const validAnchor = params.getAll("compareUntil").length <= 1 && Number.isSafeInteger(anchor) && anchor > now - 30 * 86_400_000 && anchor <= Math.max(now, actionTime); + const until = validAnchor ? anchor : null; + if (!id && !directoryVisited) setDirectoryVisited(true); + const share = new URL(window.location.pathname, window.location.origin); share.search = params.toString(); + if (comparing && until != null) share.searchParams.set("compareUntil", String(until)); + const range = observerRange(params.get("range")); + const select = (observer: string | null) => setParams(observerDestination(params, observer, range)); + const compare = (observer: string) => { + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareWith", observer); + next.set("compareUntil", String(comparing ? until ?? hourAt(clickedAt) : hourAt(clickedAt))); + return next; + }); + }; + const closeCompare = () => setParams(old => { const next = new URLSearchParams(old); next.delete("compareWith"); next.delete("compareUntil"); return next; }); + const refreshCompare = () => { + const clickedAt = Date.now(); + setActionTime(clickedAt); + setParams(old => { + const next = new URLSearchParams(old); + next.set("compareUntil", String(hourAt(clickedAt))); + return next; + }, { replace: true }); + return hourAt(clickedAt); + }; + return
+ {/* Keep the directory mounted so Back restores filters, sorting and scroll. */} + {directoryVisited &&
+ +
} + {id &&
+
+ + + + +
+
+ {t("common.loading")}

}> + +
+
+
} +
; +} diff --git a/src/features/observers/ObserverPicker.tsx b/src/features/observers/ObserverPicker.tsx new file mode 100644 index 0000000..39b5fbf --- /dev/null +++ b/src/features/observers/ObserverPicker.tsx @@ -0,0 +1,25 @@ +import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserversPage } from "../../api/client"; +import { useRegion } from "../../hooks/useRegion"; + +export function ObserverPicker({ id, name, onSelect, excludeId, label }: { id: string; name: string; onSelect: (id: string) => void; excludeId?: string; label?: string }) { + const { t } = useTranslation(); + const { iatas, regionKey } = useRegion(); + const [text, setText] = useState(""); + const [query, setQuery] = useState(""); + useEffect(() => { const timer = setTimeout(() => setQuery(text.trim()), 250); return () => clearTimeout(timer); }, [text]); + const options = useQuery({ queryKey: ["observer-picker", regionKey, query], queryFn: () => getObserversPage(iatas, { name: query || undefined, limit: 50 }), staleTime: 30_000 }); + const rows = (options.data?.items ?? []).filter(o => o.id !== excludeId); + return
+
+ setText(e.target.value)} className="min-h-11 min-w-0 flex-1 rounded border border-border bg-bg-base px-3 text-text-normal" /> + +
+ {options.isError ? :

{options.isFetching ? t("common.loading") : t("observerPage.searchHelp")}

} +
; +} diff --git a/src/features/observers/ObserverSummary.tsx b/src/features/observers/ObserverSummary.tsx new file mode 100644 index 0000000..fb4ee1b --- /dev/null +++ b/src/features/observers/ObserverSummary.tsx @@ -0,0 +1,61 @@ +import { useTranslation } from "react-i18next"; +import { Timestamp } from "../../components/Timestamp"; +import { CopyButton } from "../../components/CopyButton"; +import { formatBattery, formatRadioParts, formatUptime } from "../../lib/formatters"; +import { useTick } from "../../hooks/useTick"; +import type { Observer } from "./types"; +import type { ObserverActivity, TelemetryPoint } from "../stats/types"; + +export function ObserverSummary({ observer, activity, points, pending = false }: { observer: Observer; activity?: ObserverActivity; points: TelemetryPoint[]; pending?: boolean }) { + const { t, i18n } = useTranslation(); const now = useTick(); + const summary = activity?.summary; + const statusFresh = observer.lastStatusAt != null && now - observer.lastStatusAt < 300_000; + const lastArrival = Math.max(summary?.latestRecordedAt ?? 0, ...observer.brokers.map(b => b.lastPacketAt || 0)); + const trafficKnown = !!summary && lastArrival > 0; + const trafficFresh = trafficKnown && now - lastArrival < 300_000; + const stats = observer.statusMetadata?.stats; + const reportedNoise = stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? stats.noise_floor : null; + const noise = reportedNoise ?? points.at(-1)?.noiseFloorDb; + const cards = [ + ["records", summary?.recordedPackets.toLocaleString(i18n.resolvedLanguage) ?? "—"], + ["lastHour", summary?.lastCompleteHour.toLocaleString(i18n.resolvedLanguage) ?? "—"], + ["lastPacket", summary?.latestRecordedAt != null ? : "—"], + ["battery", observer.batteryLevel != null ? formatBattery(observer.batteryLevel) : "—"], + ["uptime", observer.uptimeSeconds != null ? formatUptime(observer.uptimeSeconds) : "—"], + ["noise", noise != null && Number.isFinite(noise) ? `${noise.toLocaleString(i18n.resolvedLanguage, { maximumFractionDigits: 1 })} dBm` : "—"], + ] as const; + return <> +
+

{observer.displayName ?? observer.id.slice(0, 8)}

+
+ {t(`observerPage.${!trafficKnown ? "trafficUnknown" : trafficFresh ? "trafficRecent" : "trafficQuiet"}`)} + {t(`observerPage.${observer.lastStatusAt == null ? "statusMissing" : statusFresh ? "statusRecent" : "statusStale"}`)} + {observer.iata} +
+
+
+
    + {cards.map(([key, value]) =>
  • +
    {t(`observerPage.${key}`)}
    +
    {value}
    + {key === "lastHour" && summary &&
    {new Date(summary.lastCompleteHourStart).toISOString().slice(11, 16)}–{new Date(summary.lastCompleteHourEnd).toISOString().slice(11, 16)} UTC
    } + {key === "noise" && noise != null &&
    {t(reportedNoise != null ? "observerPage.latestStatus" : "observerPage.latestTelemetry")}
    } +
  • )} +
+ {!summary && !pending &&

{t("observerPage.summaryMissing")}

} + ; +} + +export function ObserverDeviceDetails({ observer }: { observer: Observer }) { + const { t } = useTranslation(); + const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); + return
+ {t("observerPage.details")} +
+ {([ ["model", observer.hardwareModel], ["firmware", observer.firmwareVersion], ["client", observer.softwareVersion], ["radio", radio] ] as const).map(([key, value]) =>
{t(`observerPage.${key}`)}
{value ?? "—"}
)} +
{t("observerPage.publicKey")}
{observer.publicKey}
+
{t("observerPage.firstSeen")}
+
+
    {observer.brokers.map(b =>
  • {b.name}{t("observerPage.presence")}: {t("observerPage.packetArrival")}: {b.lastPacketAt > 0 ? : "—"}
  • )}
+
; +} diff --git a/src/features/observers/observer-navigation.ts b/src/features/observers/observer-navigation.ts new file mode 100644 index 0000000..56b97f2 --- /dev/null +++ b/src/features/observers/observer-navigation.ts @@ -0,0 +1,12 @@ +import type { StatsRange } from "../stats/types"; + +export const observerRange = (value: string | null): StatsRange => value === "24h" || value === "30d" ? value : "7d"; + +export function observerDestination(params: URLSearchParams, id: string | null, range = observerRange(params.get("range"))) { + const next = new URLSearchParams(params); + next.set("tab", "Observers"); + for (const key of ["statsTab", "observerId", "node", "hash", "analyze", "path"]) next.delete(key); + if (id) { next.set("observer", id); next.set("range", range); } + else { next.delete("observer"); next.delete("range"); next.delete("compareWith"); next.delete("compareUntil"); } + return next; +} diff --git a/src/features/packets/PacketEndpoints.tsx b/src/features/packets/PacketEndpoints.tsx index 6978839..cd9e5df 100644 --- a/src/features/packets/PacketEndpoints.tsx +++ b/src/features/packets/PacketEndpoints.tsx @@ -1,6 +1,6 @@ -import type { PacketSummary } from "../../types/api"; +import type { PacketSummary, ResolvedHop } from "../../types/api"; import { PayloadType, type PathConfidence } from "../../types/enums"; -import { buildPathSummary, type PathChip } from "./path-summary"; +import { HopPopover } from "./PathData"; // Same three-state vocabulary PathData uses in the analyzer. const CONFIDENCE_CLASSES: Record = { @@ -9,35 +9,31 @@ const CONFIDENCE_CLASSES: Record = { none: "bg-text-muted/8 text-text-dim", }; -function Chip({ chip }: { chip: PathChip }) { - if (chip.kind === "hex") { - return {chip.label}; - } - if (chip.kind === "unresolved-run") { - return ( - - {chip.count === 1 ? "?" : `?×${chip.count}`} - - ); - } +function Chip({ hop }: { hop: ResolvedHop }) { + const node = hop.nodes[0]; + const label = node ? node.name ?? node.publicKey.slice(0, 8) : "?"; return ( - - {chip.label} - + + + {label}{hop.nodes.length > 1 ? ` +${hop.nodes.length - 1}` : ""} + + ); } const Na = () => n/a; -// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them -// nil on the REST list; there the server's summary (advert name, trace tag, ack checksum) stands in. +// Keep every candidate: a short endpoint hash can match several nodes. export function PacketEndpoints({ packet }: { packet: PacketSummary }) { - const { source, destination } = buildPathSummary(packet); + const source = packet.latestObserver?.resolvedSource; + const destination = packet.latestObserver?.resolvedDestination; // An advert is the node announcing itself, so its name is the whole story — no destination. if (packet.payloadType === PayloadType.ADVERT) { - const self = source ?? (packet.summary ? { kind: "node" as const, label: packet.summary, confidence: "high" as const } : null); - return self ? : ; + if (source) return ; + return packet.summary + ? {packet.summary} + : ; } if (!source && !destination) { @@ -48,9 +44,9 @@ export function PacketEndpoints({ packet }: { packet: PacketSummary }) { return ( - {source ? : } + {source ? : } → - {destination ? : } + {destination ? : } ); } diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 90f20e1..59db7f9 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -17,7 +17,7 @@ function nodeLabel(node: ResolvedNode): string { } // Portals to so the drawer's overflow doesn't clip it; a close delay bridges the mouse gap. -function HopPopover({ hop, onViewNode, showSnr = true, children }: { +export function HopPopover({ hop, onViewNode, showSnr = true, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; showSnr?: boolean; @@ -94,7 +94,8 @@ function HopPopover({ hop, onViewNode, showSnr = true, children }: { onMouseEnter={hasHover ? open : undefined} onMouseLeave={hasHover ? scheduleClose : undefined} onClick={hasHover ? undefined : toggle} - className="inline-flex" + title={nodes.map(nodeLabel).join(", ") || "No Path Resolutions Available"} + className="inline-flex min-w-0 max-w-full" > {children} {anchor && diff --git a/src/features/packets/path-summary.ts b/src/features/packets/path-summary.ts index 8df354d..3fbcf01 100644 --- a/src/features/packets/path-summary.ts +++ b/src/features/packets/path-summary.ts @@ -15,12 +15,10 @@ export interface PathSummary { hopLabel: string; chips: PathChip[]; overflow: number; // hops not represented by a visible chip - source: PathChip | null; - destination: PathChip | null; isNa: boolean; } -const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, source: null, destination: null, isNa: true }; +const NA: PathSummary = { hopLabel: "n/a", chips: [], overflow: 0, isNa: true }; function hopChip(hop: ResolvedHop): PathChip | null { if (hop.confidence === "none") return null; @@ -29,12 +27,6 @@ function hopChip(hop: ResolvedHop): PathChip | null { return { kind: "node", label, confidence: hop.confidence }; } -// Endpoints are single hops and never collapse — an unresolved one shows "?" rather than a run. -function endpointChip(hop: ResolvedHop | undefined): PathChip | null { - if (!hop) return null; - return hopChip(hop) ?? { kind: "unresolved-run", count: 1 }; -} - function chipsFromResolved(path: ResolvedHop[]): PathChip[] { const out: PathChip[] = []; for (const hop of path) { @@ -63,9 +55,7 @@ export function buildPathSummary(packet: PacketSummary): PathSummary { if (!observer || !length) return NA; const { hopCount, hashSize } = length; - const source = endpointChip(observer.resolvedSource); - const destination = endpointChip(observer.resolvedDestination); - const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, source, destination, isNa: false }; + const base = { hopLabel: `${hopCount} hops`, chips: [] as PathChip[], overflow: 0, isNa: false }; if (hopCount === 0) return { ...base, hopLabel: "0 hops · direct" }; diff --git a/src/features/stats/ObserverTab.tsx b/src/features/stats/ObserverTab.tsx index a90fe0a..ad2d1a7 100644 --- a/src/features/stats/ObserverTab.tsx +++ b/src/features/stats/ObserverTab.tsx @@ -1,200 +1,65 @@ -import { useEffect, useMemo, useState } from "react"; -import { useQuery, keepPreviousData } from "@tanstack/react-query"; -import { Badge } from "../../components/Badge"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; import { EmptyState } from "../../components/EmptyState"; -import { formatBattery, formatCount, formatRadioParts, formatUptime } from "../../lib/formatters"; -import { getObserversPage } from "../../api/client"; -import { useRegion } from "../../hooks/useRegion"; +import { formatRadioParts } from "../../lib/formatters"; +import { ObserverPicker } from "../observers/ObserverPicker"; +import { ObserverComparison } from "../observers/ObserverComparison"; +import { ObserverSummary, ObserverDeviceDetails } from "../observers/ObserverSummary"; import { useChartColors } from "./chartTheme"; -import { useTopObservers } from "./useStats"; import { activityParamsFor, useObserver, useObserverActivity, useObserverTelemetry } from "./useTelemetry"; -import { useTick } from "../../hooks/useTick"; -import { deriveObserverStatus } from "../observers/observer-status"; -import { airtimeOption, batteryOption, noiseFloorOption, queueOption, receiveErrorsOption, busyOption, heardOption, snrHeardOption, typeBarOption } from "./chartOptions"; +import { + airtimeOption, + batteryOption, + noiseFloorOption, + queueOption, + receiveErrorsOption, + busyOption, + heardOption, + snrHeardOption, + typeBarOption, +} from "./chartOptions"; import { Card, ChartCard } from "./cards"; import { fillActivity, hasTelemetry, intervalToMs, latestAirtimePct, payloadBarItems } from "./transforms"; import { useLiveObserver } from "./useLiveStats"; import { isNotFound } from "../../api/client"; import type { WsManager } from "../../api/ws-manager"; -import type { Observer } from "../observers/types"; import { RANGE_MS, type StatsRange } from "./types"; -// how the "Heard per …" title reads for each bucket the activity endpoint returns -const PER_BUCKET: Record = { "5m": "5 min", "15m": "15 min", "1h": "hour", "6h": "6 h", "24h": "day" }; - -type AirtimePct = { rx: number | null; tx: number | null }; - -function airtimeLabel(a: AirtimePct): string { +function airtimeLabel(a: { rx: number | null; tx: number | null }): string { return [a.rx != null && `RX ${a.rx}%`, a.tx != null && `TX ${a.tx}%`].filter(Boolean).join(" · "); } - -function ObserverList({ - range, - selectedId, - onSelect, -}: { - range: StatsRange; - selectedId: string | null; - onSelect: (id: string) => void; -}) { - const { iatas, regionKey } = useRegion(); - const [query, setQuery] = useState(""); - // debounce so the server-side lookup fires once per pause, not once per keystroke - const [q, setQ] = useState(""); - useEffect(() => { - const id = setTimeout(() => setQ(query.trim()), 250); - return () => clearTimeout(id); - }, [query]); - const searching = q.length > 0; - - // default: top observers by activity (with count + activity bar). Searching swaps to a server-side - // name lookup across ALL observers in the region, not just the loaded top rows. - const top = useTopObservers(range, 15); - const max = useMemo(() => Math.max(1, ...(top.data ?? []).map((o) => o.observationCount)), [top.data]); - - const search = useQuery({ - queryKey: ["observer-search", regionKey, q], - queryFn: () => getObserversPage(iatas, { name: q, limit: 50 }), - enabled: searching, - staleTime: 30_000, - placeholderData: keepPreviousData, - }); - - type Row = { id: string; name: string; count?: number; iata?: string; online?: boolean }; - const rows: Row[] = searching - ? (search.data?.items ?? []).map((o) => ({ id: o.id, name: o.displayName ?? o.id.slice(0, 8), iata: o.iata, online: o.status === "online" })) - : (top.data ?? []).map((o) => ({ id: o.observerId, name: o.displayName ?? o.observerId.slice(0, 8), count: o.observationCount })); - - const loading = searching ? search.isLoading : top.isLoading; - - return ( - - setQuery(e.target.value)} - placeholder="Search observers…" - className="mb-2 w-full rounded border border-border bg-bg-base px-2 py-1 font-mono text-[12px] text-text-normal placeholder:text-text-dim" - /> -
- {loading &&
{searching ? "Searching…" : "Loading…"}
} - {searching && search.isError && ( -
Search failed
- )} - {!loading && !(searching && search.isError) && rows.length === 0 && ( -
{searching ? "No matches" : "No observers"}
- )} - {rows.map((r) => { - const active = r.id === selectedId; - return ( - - ); - })} -
-
- ); -} - -function ObserverHeader({ observer, airtime }: { observer: Observer; airtime: AirtimePct }) { - useTick(); // keep the recency-derived status badge fresh - const status = deriveObserverStatus(observer); - const radio = formatRadioParts({ freqMhz: observer.radioFreqMhz, sf: observer.radioSf, bwKhz: observer.radioBwKhz, cr: observer.radioCr }); - - return ( - - {observer.displayName ?? observer.id.slice(0, 8)} - {status} - {observer.observerType && {observer.observerType}} - - } - right={ - {observer.iata} - } - > -
- - - - {(airtime.rx != null || airtime.tx != null) && } - {radio && } -
-
- ); -} - -function Metric({ label, value }: { label: string; value: string }) { - return ( - - {label} - {value} - - ); -} - interface ObserverTabProps { range: StatsRange; selectedObserverId: string | null; - onSelectObserver: (observerId: string) => void; + onSelectObserver: (id: string) => void; wsManager: WsManager; + comparison?: { id: string; until: number | null; onSelect: (id: string) => void; onRefresh: () => number }; } - -export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager }: ObserverTabProps) { +export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsManager, comparison }: ObserverTabProps) { + const { t, i18n } = useTranslation(); const colors = useChartColors(); useLiveObserver(wsManager, selectedObserverId, range); - const topObservers = useTopObservers(range, 15); - const observer = useObserver(selectedObserverId); + const observer = useObserver(selectedObserverId, true); const telemetry = useObserverTelemetry(selectedObserverId, range); - const activity = useObserverActivity(selectedObserverId, range); - - // default to the busiest observer once the list loads and nothing is selected - useEffect(() => { - if (selectedObserverId) return; - const first = topObservers.data?.[0]; - if (first) onSelectObserver(first.observerId); - }, [selectedObserverId, topObservers.data, onSelectObserver]); - + const activity = useObserverActivity(selectedObserverId, range, comparison?.until ?? undefined); const points = useMemo(() => telemetry.data?.points ?? [], [telemetry.data]); - // use the response's interval, not the range prop — keepPreviousData can briefly show the old range's points - const bucketMs = telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; + // Use the response interval so the plotted bucket width always matches the data. + const bucketMs = + telemetry.data != null && telemetry.data.interval !== "1h" ? intervalToMs(telemetry.data.interval) : null; const bucketed = bucketMs != null; const airtime = useMemo(() => airtimeOption(points, colors, bucketMs), [points, colors, bucketMs]); - // bucketed values span less than their bucket, so only the hourly series gives an honest header number + // Show the latest airtime percentage only when computed from unbucketed reports. const latestAirtime = useMemo( () => (bucketMs == null && hasTelemetry(points) ? latestAirtimePct(points, null) : { rx: null, tx: null }), [points, bucketMs], ); - const battery = useMemo(() => batteryOption(points, colors), [points, colors]); - const noise = useMemo(() => noiseFloorOption(points, colors), [points, colors]); - const queue = useMemo(() => queueOption(points, colors), [points, colors]); - const recvErrors = useMemo(() => receiveErrorsOption(points, colors, bucketed), [points, colors, bucketed]); + const battery = useMemo(() => batteryOption(points, colors, t("observerPage.battery") + " V"), [points, colors, t]); + const noise = useMemo(() => noiseFloorOption(points, colors, t("observerPage.noise") + " dBm"), [points, colors, t]); + const queue = useMemo(() => queueOption(points, colors, t("observerPage.queue")), [points, colors, t]); + const recvErrors = useMemo( + () => receiveErrorsOption(points, colors, bucketed, t("observerPage.errors")), + [points, colors, bucketed, t], + ); // Bots / MQTT bridges report status but no device telemetry — show one clear empty state rather // than five flat-zero charts. When some telemetry exists, gate each chart on its own metric. @@ -204,96 +69,244 @@ export function ObserverTab({ range, selectedObserverId, onSelectObserver, wsMan const missing = (...accessors: ((p: (typeof points)[number]) => number | null)[]) => ready && !points.some((p) => accessors.some((a) => a(p) != null)); - // a 404 means this server has no activity endpoint yet: hide the heard group rather than show failed cards - const heardUnavailable = activity.isError && isNotFound(activity.error); - // a placeholder is the previous selection's data, so treat it as loading rather than read anything from it - const heardLoading = activity.isLoading || activity.isPlaceholderData; - const heardData = activity.isPlaceholderData ? undefined : activity.data; + // A 404 without cached data means this server has no activity endpoint yet. + const heardUnavailable = !activity.data && activity.isError && isNotFound(activity.error); + const heardLoading = activity.isLoading; + const heardData = activity.data; const intervalMs = heardData ? intervalToMs(heardData.interval) : null; // the window ends at the last fetch so the right edge follows now on every poll const heardWindow = useMemo(() => { - const end = activity.dataUpdatedAt; + const end = heardData?.windowEnd ?? activity.dataUpdatedAt; const span = (heardData && intervalToMs(heardData.range)) ?? RANGE_MS[range]; - return { start: end - span, end }; + return { start: heardData?.windowStart ?? end - span, end }; }, [heardData, activity.dataUpdatedAt, range]); const heard = useMemo( () => (heardData && intervalMs ? fillActivity(heardData.points, intervalMs, heardWindow) : []), [heardData, intervalMs, heardWindow], ); - const busy = useMemo(() => busyOption(heard, colors, intervalMs, heardWindow), [heard, colors, intervalMs, heardWindow]); - const heardCount = useMemo(() => heardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); - const snr = useMemo(() => snrHeardOption(heard, colors, heardWindow), [heard, colors, heardWindow]); - const payloadItems = useMemo(() => payloadBarItems(heardData?.payloadTypes ?? []), [heardData]); + const busy = useMemo( + () => busyOption(heard, colors, intervalMs, heardWindow), + [heard, colors, intervalMs, heardWindow], + ); + const heardCount = useMemo( + () => heardOption(heard, colors, heardWindow, t("observerPage.packets")), + [heard, colors, heardWindow, t], + ); + const snr = useMemo( + () => + snrHeardOption(heard, colors, heardWindow, { + average: t("observerPage.mean"), + minimum: t("observerPage.minimum"), + }), + [heard, colors, heardWindow, t], + ); + const payloadItems = useMemo( + () => + payloadBarItems(heardData?.payloadTypes ?? []).map((item) => + item.name.toLowerCase() === "unknown" ? { ...item, name: t("observerPage.unknown") } : item, + ), + [heardData, t], + ); const payload = useMemo(() => typeBarOption(payloadItems, colors), [payloadItems, colors]); const nothingHeard = heardData != null && heardData.points.length === 0; const costed = heardData?.points.some((p) => p.airtimeMs != null) ?? false; const heardSnr = heard.some((p) => p.snrAvg != null); const radioLabel = formatRadioParts(heardData?.radio ?? {}); const interval = heardData?.interval ?? activityParamsFor(range).interval; - const perBucket = PER_BUCKET[interval] ?? interval; + const perBucket = + interval === "1h" + ? t("observerPage.hour") + : interval === "24h" + ? t("observerPage.day") + : interval.replace("m", " min").replace("h", " h"); - return ( -
-
- + if (!selectedObserverId) return ; + if (observer.isError) + return ( +
+

{t("observerPage.loadFailed")}

+
- -
- {!selectedObserverId ? ( - - - - ) : ( - <> - {observer.data && } - {noTelemetry ? ( - - - - ) : ( - <> - Airtime TX / RX · {range}} - height={180} - option={airtime} - isLoading={telemetry.isLoading} - isError={telemetry.isError} - isEmpty={missing((p) => p.airtimeTxSecs, (p) => p.airtimeRxSecs)} - /> -
- p.batteryMv)} /> - p.noiseFloorDb)} /> - p.queueLength)} /> - p.receiveErrors)} /> -
- - )} - {!heardUnavailable && - (nothingHeard ? ( - - - - ) : ( - <> - Channel busy · {range}} - right={radioLabel && {radioLabel}} - height={180} - option={busy} - isLoading={heardLoading} - isError={activity.isError} - isEmpty={heardData != null && !costed} - /> -
- - -
- - - ))} - + ); + if (!observer.data) + return ( +

+ {t("observerPage.recording")} +

+ ); + const date = (value: number) => + new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" }); + return ( +
+ + +
+

{t("observerPage.scopeNote")}

+ {heardData?.windowStart != null && heardData.windowEnd != null && ( +

+ {t("observerPage.window")}: {date(heardData.windowStart)} – {date(heardData.windowEnd)} +

)} +

{t("observerPage.windowHelp")}

-
+ {comparison && { + const nextUntil = comparison.onRefresh(); + void observer.refetch(); + void telemetry.refetch(); + if (nextUntil === comparison.until) void activity.refetch(); + return nextUntil; + }} />} + {heardUnavailable ? ( + +

{t("observerPage.summaryMissing")}

+
+ ) : nothingHeard ? ( + + + + ) : ( + <> +
+ + + +
+ {activity.isError && ( + + )} + {heardData && ( +
+ {t("observerPage.exact")} +
+ + + + + + + + + + + {heardData.points.map((p) => ( + + + + + + + ))} + +
{t("observerPage.time")}{t("observerPage.packets")}SNR (dB)RSSI (dBm)
{date(p.t)}{p.observations.toLocaleString()}{p.snrAvg?.toFixed(1) ?? "—"}{p.rssiAvg?.toFixed(1) ?? "—"}
+
+
+ )} + + )} +

{t("observerPage.device")}

+ {noTelemetry ? ( + +

{t("observerPage.noTelemetryHelp")}

+
+ ) : ( + <> + {airtimeLabel(latestAirtime)}} + height={200} + option={airtime} + isLoading={telemetry.isLoading} + isError={!telemetry.data && telemetry.isError} + isEmpty={missing( + (p) => p.airtimeTxSecs, + (p) => p.airtimeRxSecs, + )} + /> +
+ p.batteryMv)} + /> + p.noiseFloorDb)} + /> + p.queueLength)} + /> + p.receiveErrors)} + /> +
+ {telemetry.isError && ( + + )} + + )} + {!heardUnavailable && costed && ( + {radioLabel}} + height={180} + option={busy} + isLoading={heardLoading} + isError={!heardData && activity.isError} + /> + )} + + ); } diff --git a/src/features/stats/StatsOverview.tsx b/src/features/stats/StatsOverview.tsx index aa920f9..cea098c 100644 --- a/src/features/stats/StatsOverview.tsx +++ b/src/features/stats/StatsOverview.tsx @@ -9,12 +9,12 @@ import { PathsTab } from "./PathsTab"; import { ScopesTab } from "./ScopesTab"; import { TalkersTab } from "./TalkersTab"; import { ClockDriftTab } from "./ClockDriftTab"; -import { ObserverTab } from "./ObserverTab"; +import { observerDestination } from "../observers/observer-navigation"; import { CompareObserversTab } from "./CompareObserversTab"; import { NeighbourGraphTab } from "./NeighbourGraphTab"; import type { StatsRange, StatsTab } from "./types"; -const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "observer", "compare", "graph"]; +const TABS: StatsTab[] = ["mesh", "traffic", "signal", "paths", "scopes", "talkers", "clockdrift", "compare", "graph"]; const RANGES: StatsRange[] = ["24h", "7d", "30d"]; const asTab = (v: string | null): StatsTab => (TABS.includes(v as StatsTab) ? (v as StatsTab) : "mesh"); @@ -24,14 +24,13 @@ interface StatsOverviewProps { wsManager: WsManager; } -// Stats page shell: a sub-header bar (Mesh / Observer pills + range) over the active +// Stats page shell: an analytics sub-header and range over the active // sub-tab. Sub-tab, range, and selected observer live in the URL (?statsTab/?range/?observerId) so the // view is shareable; replace:true keeps it out of history. Queries are cached, so switching is instant. export function StatsOverview({ wsManager }: StatsOverviewProps) { const [params, setParams] = useSearchParams(); const tab = asTab(params.get("statsTab")); const range = asRange(params.get("range")); - const observerId = params.get("observerId"); const patch = useCallback( (updates: Record) => { @@ -52,7 +51,7 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { const handleTab = useCallback((t: StatsTab) => patch({ statsTab: t }), [patch]); const handleRange = useCallback((r: StatsRange) => patch({ range: r }), [patch]); - const handleSelectObserver = useCallback((id: string) => patch({ statsTab: "observer", observerId: id }), [patch]); + const handleSelectObserver = useCallback((id: string) => setParams(observerDestination(params, id)), [params, setParams]); return (
@@ -65,9 +64,6 @@ export function StatsOverview({ wsManager }: StatsOverviewProps) { {tab === "scopes" && } {tab === "talkers" && } {tab === "clockdrift" && } - {tab === "observer" && ( - - )} {tab === "graph" && } {tab === "compare" && }
diff --git a/src/features/stats/StatsSubHeader.tsx b/src/features/stats/StatsSubHeader.tsx index aaf201f..083bfe3 100644 --- a/src/features/stats/StatsSubHeader.tsx +++ b/src/features/stats/StatsSubHeader.tsx @@ -34,15 +34,6 @@ function MeshIcon() { ); } -function ObserverIcon() { - return ( - - - - - ); -} - function SignalIcon() { return @@ -95,7 +86,6 @@ const TAB_OPTIONS = [ { value: "scopes", icon: }, { value: "talkers", icon: }, { value: "clockdrift", icon: }, - { value: "observer", icon: }, { value: "compare", icon: }, { value: "graph", icon: }, ]; diff --git a/src/features/stats/chartOptions.ts b/src/features/stats/chartOptions.ts index b4154fd..e03d085 100644 --- a/src/features/stats/chartOptions.ts +++ b/src/features/stats/chartOptions.ts @@ -371,18 +371,18 @@ function metricLineOption( }; } -export const batteryOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Battery V", color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); +export const batteryOption = (p: TelemetryPoint[], c: ChartColors, name = "Battery V") => + metricLineOption(p, c, { name, color: c.primary, accessor: (x) => (x.batteryMv == null ? null : +(x.batteryMv / 1000).toFixed(3)) }); -export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Noise dBm", color: c.warn, accessor: (x) => x.noiseFloorDb }); +export const noiseFloorOption = (p: TelemetryPoint[], c: ChartColors, name = "Noise dBm") => + metricLineOption(p, c, { name, color: c.warn, accessor: (x) => x.noiseFloorDb }); -export const queueOption = (p: TelemetryPoint[], c: ChartColors) => - metricLineOption(p, c, { name: "Queue", color: c.secondary, accessor: (x) => x.queueLength, area: true }); +export const queueOption = (p: TelemetryPoint[], c: ChartColors, name = "Queue") => + metricLineOption(p, c, { name, color: c.secondary, accessor: (x) => x.queueLength, area: true }); // receiveErrors is a cumulative counter in raw points, a per-bucket delta in bucketed ones -export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean) => - metricLineOption(p, c, { name: "Recv errors", color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); +export const receiveErrorsOption = (p: TelemetryPoint[], c: ChartColors, bucketed: boolean, name = "Recv errors") => + metricLineOption(p, c, { name, color: c.danger, accessor: (x) => x.receiveErrors, delta: !bucketed, area: true }); // ---- Observer activity (what it heard) ---- @@ -425,7 +425,7 @@ export function busyOption(points: ActivityPoint[], c: ChartColors, intervalMs: }; } -export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { +export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, name = "Heard"): EChartsOption { return { animation: false, backgroundColor: "transparent", @@ -435,7 +435,7 @@ export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWind yAxis: valueAxis(c, { minInterval: 1 }), series: [ { - name: "Heard", + name, type: "line", symbol: "none", data: points.map((p) => [p.t, p.observations]), @@ -449,18 +449,18 @@ export function heardOption(points: ActivityPoint[], c: ChartColors, w: TimeWind const dbLabel = (v: unknown) => (typeof v === "number" ? `${v} dB` : "—"); -export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow): EChartsOption { +export function snrHeardOption(points: ActivityPoint[], c: ChartColors, w: TimeWindow, labels = { average: "Avg", minimum: "Min" }): EChartsOption { return { animation: false, backgroundColor: "transparent", grid: { left: 54, right: 14, top: 24, bottom: 22 }, - legend: { data: ["Avg", "Min"], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, + legend: { data: [labels.average, labels.minimum], right: 6, top: 0, itemWidth: 10, itemHeight: 10, textStyle: { color: c.textNormal, fontFamily: MONO, fontSize: 10 } }, tooltip: { trigger: "axis", ...tooltipStyle(c), valueFormatter: dbLabel }, xAxis: windowAxis(c, w), yAxis: valueAxis(c, { scale: true, axisLabel: { color: c.textMuted, fontFamily: MONO, fontSize: 10, formatter: "{value} dB" } }), series: [ - { name: "Avg", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, - { name: "Min", type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, + { name: labels.average, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrAvg]), lineStyle: { width: 1.8, color: c.secondary }, itemStyle: { color: c.secondary } }, + { name: labels.minimum, type: "line", symbol: "none", connectNulls: false, data: points.map((p) => [p.t, p.snrMin]), lineStyle: { width: 1, color: c.textMuted, type: "dashed" }, itemStyle: { color: c.textMuted } }, ], }; } diff --git a/src/features/stats/types.ts b/src/features/stats/types.ts index 0750518..7beff78 100644 --- a/src/features/stats/types.ts +++ b/src/features/stats/types.ts @@ -144,7 +144,19 @@ export interface ActivityRadio { preambleSymbols: number | null; } +export interface ObserverActivitySummary { + recordedPackets: number; + lastCompleteHour: number; + lastCompleteHourStart: number; + lastCompleteHourEnd: number; + latestRecordedAt: number | null; +} export interface ObserverActivity { + windowStart?: number; + windowEnd?: number; + generatedAt?: number; + source?: "raw" | "hourly"; + summary?: ObserverActivitySummary; range: string; interval: string; radio: ActivityRadio | null; diff --git a/src/features/stats/useTelemetry.ts b/src/features/stats/useTelemetry.ts index 217f4c3..a65e309 100644 --- a/src/features/stats/useTelemetry.ts +++ b/src/features/stats/useTelemetry.ts @@ -1,4 +1,4 @@ -import { useQuery, keepPreviousData } from "@tanstack/react-query"; +import { useQuery } from "@tanstack/react-query"; import { getObserver, getObserverTelemetry, getObserverActivity, isNotFound } from "../../api/client"; import type { StatsRange } from "./types"; @@ -17,12 +17,13 @@ const INTERVAL_PARAM: Record = { "30d": "24h", }; -export function useObserver(observerId: string | null) { +export function useObserver(observerId: string | null, poll = false) { return useQuery({ queryKey: ["observer", observerId], queryFn: () => getObserver(observerId!), enabled: !!observerId, staleTime: 30_000, + refetchInterval: poll ? 60_000 : false, refetchOnWindowFocus: false, }); } @@ -43,16 +44,15 @@ export function activityRefetchInterval(error: unknown): number | false { return isNotFound(error) ? false : 60_000; } -export function useObserverActivity(observerId: string | null, range: StatsRange) { +export function useObserverActivity(observerId: string | null, range: StatsRange, until?: number) { const params = activityParamsFor(range); return useQuery({ - queryKey: ["observer-activity", observerId, range], - queryFn: () => getObserverActivity(observerId!, params.range, params.interval), + queryKey: ["observer-activity", observerId, range, until], + queryFn: () => getObserverActivity(observerId!, params.range, params.interval, until), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, - refetchInterval: (query) => activityRefetchInterval(query.state.error), + refetchInterval: (query) => until == null ? activityRefetchInterval(query.state.error) : false, }); } @@ -63,7 +63,6 @@ export function useObserverTelemetry(observerId: string | null, range: StatsRang queryFn: () => getObserverTelemetry(observerId!, RANGE_PARAM[range], interval), enabled: !!observerId, staleTime: 30_000, - placeholderData: keepPreviousData, refetchOnWindowFocus: false, }); } diff --git a/src/hooks/useTick.ts b/src/hooks/useTick.ts index 803552d..4fd666f 100644 --- a/src/hooks/useTick.ts +++ b/src/hooks/useTick.ts @@ -1,11 +1,11 @@ -import { useSyncExternalStore } from "react"; +import { useCallback, useSyncExternalStore } from "react"; // Forces a re-render on a fixed interval so relative time labels ("2m ago") stay fresh. Backed by one // shared interval per interval-length (module-level), so the many instances across the app // subscribe to a single timer instead of each spinning up its own setInterval. interface Ticker { - version: number; + time: number; listeners: Set<() => void>; id: ReturnType | null; } @@ -15,7 +15,7 @@ const tickers = new Map(); function getTicker(intervalMs: number): Ticker { let t = tickers.get(intervalMs); if (!t) { - t = { version: 0, listeners: new Set(), id: null }; + t = { time: Date.now(), listeners: new Set(), id: null }; tickers.set(intervalMs, t); } return t; @@ -25,8 +25,9 @@ function subscribe(intervalMs: number, listener: () => void): () => void { const t = getTicker(intervalMs); t.listeners.add(listener); if (t.id === null) { + t.time = Date.now(); t.id = setInterval(() => { - t!.version++; + t!.time = Date.now(); t!.listeners.forEach((l) => l()); }, intervalMs); } @@ -39,10 +40,11 @@ function subscribe(intervalMs: number, listener: () => void): () => void { }; } -export function useTick(intervalMs = 10_000): void { - useSyncExternalStore( - (listener) => subscribe(intervalMs, listener), - () => getTicker(intervalMs).version, +export function useTick(intervalMs = 10_000): number { + const listen = useCallback((listener: () => void) => subscribe(intervalMs, listener), [intervalMs]); + return useSyncExternalStore( + listen, + () => getTicker(intervalMs).time, () => 0, ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 396320e..10c35b7 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -223,6 +223,94 @@ }, "timestamp": { "ago": "{{duration}} ago" + }, + "observerPage": { + "back": "Back to observers", + "range": "Time range", + "open": "Open observer dashboard", + "legacy": "Legacy presence count", + "metrics": "Observer metrics", + "records": "Recorded packets", + "lastHour": "Last complete hour", + "lastPacket": "Last recorded packet", + "battery": "Battery", + "uptime": "Device uptime", + "noise": "Noise floor", + "packets": "Packets", + "search": "Find an observer", + "choose": "Choose an observer", + "searchHelp": "Search by name to find observers outside the first 50 results.", + "summaryMissing": "Packet summary unavailable on this server. Charts remain available.", + "trafficRecent": "Recent packet traffic", + "trafficQuiet": "No recent recorded packets", + "statusRecent": "Recent status", + "statusStale": "Status is stale", + "statusMissing": "No status report", + "scopeNote": "History for this observer across all received regions.", + "activity": "Recorded packets per {{interval}}", + "mix": "Packet-type mix", + "signal": "Received signal", + "airtime": "Airtime TX / RX", + "device": "Device telemetry", + "details": "Device details", + "queue": "Queue length", + "errors": "Receive errors", + "busy": "Channel busy", + "empty": "No packets recorded in this period", + "noTelemetry": "No telemetry reported", + "noTelemetryHelp": "This observer reports no device telemetry for this period.", + "loadFailed": "Observer could not be loaded.", + "retry": "Retry", + "model": "Model", + "firmware": "Firmware", + "client": "Client", + "radio": "Radio", + "publicKey": "Public key", + "copy": "Copy", + "copied": "Copied", + "broker": "Broker", + "presence": "Last contact", + "packetArrival": "Packet arrival", + "firstSeen": "First seen", + "copyLink": "Copy observer link", + "updated": "Calculated", + "window": "UTC window", + "windowHelp": "Complete buckets; gaps are missing records, not proof of an outage. Repeated deliveries of the same retained packet/observer pair count once.", + "exact": "Exact activity values", + "time": "Time (UTC)", + "mean": "Average", + "minimum": "Minimum", + "unknown": "Unknown", + "latestStatus": "Latest status", + "latestTelemetry": "Latest telemetry interval", + "recording": "Loading observer…", + "hour": "hour", + "day": "day", + "trafficUnknown": "Packet freshness unavailable" + }, + "observerCompare": { + "open": "Compare with…", + "close": "Close comparison", + "title": "Compare observers", + "partner": "Observer B", + "choose": "Choose an observer to compare", + "invalidObserver": "Choose a different observer with a valid identifier.", + "invalidTime": "This comparison time is invalid or too old. Refresh to use the latest complete window.", + "refresh": "Refresh both observers", + "unavailable": "Aligned summaries are unavailable. Refresh both observers, or update the server to support shared windows.", + "currentNote": "Activity totals use this shared window. Device values and the latest complete hour describe current conditions; the hour start is shown in UTC.", + "metrics": "Observer comparison metrics", + "metric": "Metric", + "activity": "Recorded packets · aligned activity", + "overlap": "Retained flood-packet overlap", + "retention": "Raw packet history may expire before the longer-lived activity summaries. Overlap counts only retained flood packets in the shared window, across all regions.", + "total_one": "{{count}} distinct flood packet", + "total_other": "{{count}} distinct flood packets", + "empty": "No retained flood packets were reported by either observer in this window.", + "onlyA": "Only A", + "both": "Both", + "onlyB": "Only B", + "definition": "Each flood packet hash counts once across both observers. Repeat receptions are deduplicated. This is not a packet-loss measurement: expiry, offline devices and broker interruptions affect the available reports." } } } diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c659426..c60ff19 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -227,6 +227,94 @@ }, "timestamp": { "ago": "il y a {{duration}}" + }, + "observerPage": { + "back": "Retour aux observateurs", + "range": "Période", + "open": "Ouvrir le tableau de bord", + "legacy": "Ancien compteur de présence", + "metrics": "Mesures de l’observateur", + "records": "Paquets enregistrés", + "lastHour": "Dernière heure complète", + "lastPacket": "Dernier paquet enregistré", + "battery": "Batterie", + "uptime": "Durée de fonctionnement", + "noise": "Bruit de fond", + "packets": "Paquets", + "search": "Rechercher un observateur", + "choose": "Choisir un observateur", + "searchHelp": "Recherchez par nom pour trouver les observateurs au-delà des 50 premiers résultats.", + "summaryMissing": "Résumé des paquets indisponible sur ce serveur. Les graphiques restent disponibles.", + "trafficRecent": "Trafic de paquets récent", + "trafficQuiet": "Aucun paquet récent enregistré", + "statusRecent": "État récent", + "statusStale": "État périmé", + "statusMissing": "Aucun rapport d’état", + "scopeNote": "Historique de cet observateur, toutes régions de réception confondues.", + "activity": "Paquets enregistrés par {{interval}}", + "mix": "Répartition des types", + "signal": "Signal reçu", + "airtime": "Temps radio TX / RX", + "device": "Télémétrie de l’appareil", + "details": "Détails de l’appareil", + "queue": "File d’attente", + "errors": "Erreurs de réception", + "busy": "Occupation du canal", + "empty": "Aucun paquet enregistré sur cette période", + "noTelemetry": "Aucune télémétrie reçue", + "noTelemetryHelp": "Cet observateur ne rapporte aucune télémétrie pour cette période.", + "loadFailed": "Impossible de charger l’observateur.", + "retry": "Réessayer", + "model": "Modèle", + "firmware": "Micrologiciel", + "client": "Client", + "radio": "Radio", + "publicKey": "Clé publique", + "copy": "Copier", + "copied": "Copié", + "broker": "Courtier", + "presence": "Dernier contact", + "packetArrival": "Réception de paquet", + "firstSeen": "Première réception", + "copyLink": "Copier le lien de l’observateur", + "updated": "Calculé", + "window": "Période UTC", + "windowHelp": "Périodes complètes ; les lacunes sont des données manquantes, pas la preuve d’une panne. Les livraisons répétées du même couple paquet/observateur conservé ne comptent qu’une fois.", + "exact": "Valeurs exactes de l’activité", + "time": "Heure (UTC)", + "mean": "Moyenne", + "minimum": "Minimum", + "unknown": "Inconnu", + "latestStatus": "Dernier état", + "latestTelemetry": "Dernière période de télémétrie", + "recording": "Chargement de l’observateur…", + "hour": "heure", + "day": "jour", + "trafficUnknown": "Fraîcheur des paquets inconnue" + }, + "observerCompare": { + "open": "Comparer avec…", + "close": "Fermer la comparaison", + "title": "Comparer les observateurs", + "partner": "Observateur B", + "choose": "Choisissez un observateur à comparer", + "invalidObserver": "Choisissez un autre observateur avec un identifiant valide.", + "invalidTime": "La date de comparaison est invalide ou trop ancienne. Actualisez pour utiliser la dernière période complète.", + "refresh": "Actualiser les deux observateurs", + "unavailable": "Les résumés alignés sont indisponibles. Actualisez les deux observateurs ou mettez le serveur à jour pour prendre en charge les périodes communes.", + "currentNote": "Les totaux d’activité utilisent cette période commune. Les valeurs de l’appareil et la dernière heure complète décrivent les conditions actuelles; le début de l’heure est indiqué en UTC.", + "metrics": "Mesures de comparaison des observateurs", + "metric": "Mesure", + "activity": "Paquets enregistrés · activité alignée", + "overlap": "Recoupement des paquets flood conservés", + "retention": "L’historique des paquets bruts peut expirer avant les résumés d’activité. Le recoupement compte uniquement les paquets flood conservés dans la période commune, toutes régions confondues.", + "total_one": "{{count}} paquet flood distinct", + "total_other": "{{count}} paquets flood distincts", + "empty": "Aucun paquet flood conservé n’a été signalé par ces observateurs pendant cette période.", + "onlyA": "A seulement", + "both": "Les deux", + "onlyB": "B seulement", + "definition": "Chaque empreinte de paquet flood est comptée une seule fois pour les deux observateurs. Les réceptions répétées sont dédupliquées. Ce n’est pas une mesure de perte de paquets : l’expiration, les appareils hors ligne et les interruptions des courtiers affectent les rapports disponibles." } } } diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx new file mode 100644 index 0000000..80b8142 --- /dev/null +++ b/tests/App.quickObserver.test.tsx @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import type { ReactNode } from "react"; +import { App } from "../src/App"; +import { ModalOverlay } from "../src/components/ModalOverlay"; + +vi.mock("../src/api/ws-manager", () => ({ WsManager: class { connect() {} disconnect() {} updateSubscription() {} } })); +vi.mock("../src/api/client", () => ({ getRegions: async () => [], getRegion: async () => ({ iatas: [] }) })); +vi.mock("../src/components/SplashScreen", () => ({ SplashScreen: () => null })); +vi.mock("../src/components/AppShell", () => ({ AppShell: ({ children }: { children: ReactNode }) => <>{children} })); +vi.mock("../src/features/nodes/NodeTable", () => ({ NodeTable: () => null })); +vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ onViewObserver, onAnalyzePacket }: { onViewObserver: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <> })); +vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string) => void }) => })); +vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 4 } } : undefined, isLoading: false }) })); +vi.mock("../src/features/packets/PacketAnalyzerOverlay", () => ({ PacketAnalyzerOverlay: ({ detail, inactive, onClose, onViewObserver }: { detail?: { packetHash: string }; inactive?: boolean; onClose: () => void; onViewObserver: (id: string) => void }) =>

{detail?.packetHash}

})); + +vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onViewObserver }: { detail?: { packetHash: string }; onViewObserver: (id: string) => void }) => <>

{detail?.packetHash}

})); + +beforeEach(() => { window.history.replaceState({}, "", "/?tab=Nodes&node=node-a"); }); + +describe("quick observer investigation", () => { + it("closes the quick observer before opening its advert analyzer", () => { + render(); + fireEvent.click(screen.getByText("Node observer")); + fireEvent.click(screen.getByText("Observer advert")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getByRole("heading")).toHaveTextContent("advert"); + expect(screen.queryByRole("button", { name: "Observer advert" })).not.toBeInTheDocument(); + }); + it("marks the underlying packet inactive while its observer is open", () => { + render(); + fireEvent.click(screen.getByText("Node packet")); + fireEvent.click(screen.getByText("Packet observer")); + expect(screen.getAllByRole("dialog")).toHaveLength(1); + expect(screen.getAllByRole("dialog", { hidden: true }).filter(node => node.getAttribute("aria-modal") === "false")).toHaveLength(1); + }); +}); diff --git a/tests/api/observer-comparison.test.ts b/tests/api/observer-comparison.test.ts index ed34602..539c7eb 100644 --- a/tests/api/observer-comparison.test.ts +++ b/tests/api/observer-comparison.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, it, vi } from "vitest"; -import { getObserverComparison } from "../../src/api/client"; +import { getObserverComparison, getObserverActivity, getObserver } from "../../src/api/client"; afterEach(() => vi.unstubAllGlobals()); @@ -15,3 +15,21 @@ it("sends the complete comparison scope, including an epoch-zero start, and cons controller.abort(); expect(options.signal.aborted).toBe(true); }); + +it("sends the shared activity anchor without altering the selected range", async () => { + const fetcher = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ points: [] }) }); + vi.stubGlobal("fetch", fetcher); + await getObserverActivity("a", "168h", "1h", 123456789); + expect(Object.fromEntries(new URL(fetcher.mock.calls[0][0]).searchParams)).toEqual({ range: "168h", interval: "1h", until: "123456789" }); +}); + +it.each([ { stats: { noise_floor: -117 } }, btoa('{"stats":{"noise_floor":-117}}') ])("reads both object and legacy byte-encoded status metadata", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + expect((await getObserver("a")).statusMetadata).toEqual({ stats: { noise_floor: -117 } }); +}); + +it.each(["not base64!", btoa('null'), btoa('[]')])("ignores malformed metadata without losing the observer", async metadata => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, json: async () => ({ id: "a", statusMetadata: metadata }) })); + const observer = await getObserver("a"); + expect(observer.id).toBe("a"); expect(observer.statusMetadata).toBeUndefined(); +}); diff --git a/tests/features/observers/ObserverComparison.test.tsx b/tests/features/observers/ObserverComparison.test.tsx new file mode 100644 index 0000000..72b6444 --- /dev/null +++ b/tests/features/observers/ObserverComparison.test.tsx @@ -0,0 +1,87 @@ +import { beforeEach, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import "../../../src/i18n"; +import { ObserverComparison } from "../../../src/features/observers/ObserverComparison"; +import { getObserverActivity, getObserverComparison } from "../../../src/api/client"; +import type { Observer } from "../../../src/features/observers/types"; +import type { ObserverActivity } from "../../../src/features/stats/types"; + +const A = "11111111-1111-1111-1111-111111111111"; +const B = "22222222-2222-2222-2222-222222222222"; +const until = Math.floor(Date.now() / 3_600_000) * 3_600_000; +const observer = { id: A, displayName: "Roof A", brokers: [], iata: "YOW" } as unknown as Observer; +const activity: ObserverActivity = { range: "168h", interval: "1h", windowStart: until - 604_800_000, windowEnd: until, generatedAt: until, source: "hourly", radio: null, payloadTypes: [], points: [], summary: { recordedPackets: 19, lastCompleteHour: 1, lastCompleteHourStart: until - 3_600_000, lastCompleteHourEnd: until, latestRecordedAt: null } }; +vi.mock("../../../src/hooks/useRegion", () => ({ useRegion: () => ({ regionKey: "YOW", iatas: ["YOW"] }) })); +vi.mock("../../../src/features/stats/EChart", () => ({ EChart: ({ option }: { option: unknown }) => {JSON.stringify(option)} })); +vi.mock("../../../src/api/client", async original => ({ + ...await original(), + getObserver: vi.fn(async (id: string) => ({ ...observer, id, displayName: "Roof B" })), + getObserversPage: vi.fn(async () => ({ items: [{ ...observer, id: B, displayName: "Roof B" }], hasMore: false })), + getObserverActivity: vi.fn(), getObserverComparison: vi.fn(), +})); +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, summary: { ...activity.summary!, recordedPackets: 23 } }); + vi.mocked(getObserverComparison).mockResolvedValue({ observerA: A, observerB: B, since: activity.windowStart!, until, onlyA: 2, both: 3, onlyB: 4, totalPackets: 9 }); +}); +function view(id = B, data = activity, anchor: number | null = until) { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + const props = { observerA: observer, activityA: data, range: "7d" as const, observerBId: id, until: anchor, onSelect: vi.fn(), onRefresh: vi.fn(() => anchor ?? until) }; + const wrapper = ({ children }: { children: React.ReactNode }) => {children}; + return { ...render(, { wrapper }), props }; +} +it("aligns activity and retained overlap to the primary observer's effective window", async () => { + view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + expect(screen.getByText("19")).toBeInTheDocument(); + expect(getObserverActivity).toHaveBeenCalledWith(B, "168h", "1h", until); + expect(getObserverComparison).toHaveBeenCalledWith(undefined, { observerA: A, observerB: B, since: activity.windowStart, until }, expect.any(AbortSignal)); + expect(screen.getByText("Retained flood-packet overlap")).toBeInTheDocument(); + expect(screen.getByText(/Raw packet history may expire/)).toBeInTheDocument(); + expect(screen.getByTestId("chart").textContent).toContain('"name":"A"'); + expect(screen.getByTestId("chart").textContent).toContain('"name":"B"'); +}); +it("does not compare mismatched or old-server windows", async () => { + vi.mocked(getObserverActivity).mockResolvedValue({ ...activity, windowEnd: until - 3_600_000 }); + view(); + expect(await screen.findByText(/Aligned summaries are unavailable/)).toBeInTheDocument(); + expect(getObserverComparison).not.toHaveBeenCalled(); + expect(screen.queryByTestId("chart")).not.toBeInTheDocument(); +}); +it.each([A, "broken-id", ""]) ("never requests overlap for an invalid or missing partner: %s", async id => { + view(id); + expect((await screen.findAllByText(id ? /Choose a different observer/ : /Choose an observer to compare/)).length).toBeGreaterThan(0); + expect(getObserverActivity).not.toHaveBeenCalled(); + expect(getObserverComparison).not.toHaveBeenCalled(); +}); +it("rejects a bad anchor and offers a fresh common window", async () => { + const { props } = view(B, activity, null); + expect(await screen.findByText(/This comparison time is invalid/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + expect(props.onRefresh).toHaveBeenCalled(); + expect(getObserverActivity).not.toHaveBeenCalled(); +}); +it("clears the previous partner's totals while the next request is pending", async () => { + const { props, rerender } = view(); + expect(await screen.findByText("23")).toBeInTheDocument(); + vi.mocked(getObserverActivity).mockImplementation(() => new Promise(() => {})); + rerender(); + await waitFor(() => expect(screen.queryByText("23")).not.toBeInTheDocument()); + expect(screen.queryByText("9 distinct flood packets")).not.toBeInTheDocument(); +}); + +it("refreshes the current window without requesting the obsolete window after re-anchoring", async () => { + const { props } = view(); + await waitFor(() => expect(getObserverComparison).toHaveBeenCalledTimes(1)); + const activityCalls = vi.mocked(getObserverActivity).mock.calls.length; + props.onRefresh.mockReturnValue(until + 3_600_000); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls); + expect(getObserverComparison).toHaveBeenCalledTimes(1); + + props.onRefresh.mockReturnValue(until); + fireEvent.click(screen.getByRole("button", { name: "Refresh both observers" })); + await waitFor(() => expect(getObserverActivity).toHaveBeenCalledTimes(activityCalls + 1)); + expect(getObserverComparison).toHaveBeenCalledTimes(2); +}); diff --git a/tests/features/observers/ObserverPage.test.tsx b/tests/features/observers/ObserverPage.test.tsx new file mode 100644 index 0000000..93cc494 --- /dev/null +++ b/tests/features/observers/ObserverPage.test.tsx @@ -0,0 +1,110 @@ +import { describe, it, expect, vi } from "vitest"; +import "../../../src/i18n"; +import { useState } from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { MemoryRouter, useNavigate, useLocation } from "react-router-dom"; +import type { WsManager } from "../../../src/api/ws-manager"; +import { ObserverPage } from "../../../src/features/observers/ObserverPage"; + +vi.mock("../../../src/features/observers/ObserverTable", () => ({ + ObserverTable: ({ onSelectObserver }: { onSelectObserver: (id: string) => void }) => { + const [search, setSearch] = useState(""); + return ( +
+ setSearch(e.target.value)} /> + +
+ ); + }, +})); +vi.mock("../../../src/features/stats/ObserverTab", () => ({ + ObserverTab: ({ + selectedObserverId, + range, + comparison, + }: { + selectedObserverId: string; + range: string; + comparison?: { until: number | null; onRefresh: () => void }; + }) => ( + <> +

+ Dashboard {selectedObserverId} {range} +

+ {comparison && ( + <> + {String(comparison.until)} + + + )} + + ), +})); +function Location() { + const l = useLocation(); + const go = useNavigate(); + return ( + <> + {l.search} + + + + ); +} +function view(url = "?tab=Observers&iata=YOW") { + return render( + + + + , + ); +} + +describe("Observer destination", () => { + it("opens a canonical dashboard and restores directory state with Back", async () => { + view(); + fireEvent.change(screen.getByLabelText("Directory search"), { target: { value: "roof" } }); + fireEvent.click(screen.getByText("Open A")); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-a 7d"); + expect(screen.getByRole("status").textContent).toContain("observer=observer-a"); + expect(screen.getByRole("status").textContent).toContain("iata=YOW"); + fireEvent.click(screen.getByText("Browser back")); + expect(screen.queryByRole("heading")).not.toBeInTheDocument(); + expect(screen.getByLabelText("Directory search")).toHaveValue("roof"); + fireEvent.click(screen.getByText("Browser forward")); + expect(await screen.findByRole("heading")).toHaveTextContent("observer-a"); + }); + it("restores a deep link and has a directory return without prior history", async () => { + view("?tab=Observers&observer=observer-b&range=30d"); + expect(await screen.findByRole("heading")).toHaveTextContent("Dashboard observer-b 30d"); + fireEvent.click(screen.getByRole("button", { name: /Back to observers/ })); + expect(screen.getByLabelText("Directory search")).toBeVisible(); + }); +}); + +it("anchors a new comparison at the click time and accepts Refresh across an hour boundary", async () => { + const clock = vi.spyOn(Date, "now").mockReturnValue(Date.UTC(2026, 8, 29, 12, 30)); + try { + view("?tab=Observers&observer=observer-a&range=24h"); + await screen.findByRole("heading"); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 15, 59, 59)); + fireEvent.click(screen.getByRole("button", { name: "Compare with…" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 15))); + clock.mockReturnValue(Date.UTC(2026, 8, 29, 16, 0, 1)); + fireEvent.click(screen.getByRole("button", { name: "Refresh comparison" })); + expect(screen.getByTestId("comparison-until")).toHaveTextContent(String(Date.UTC(2026, 8, 29, 16))); + fireEvent.click(screen.getByRole("button", { name: "Close comparison" })); + expect(screen.queryByTestId("comparison-until")).not.toBeInTheDocument(); + expect(screen.getByRole("status")).not.toHaveTextContent("compareUntil"); + } finally { + clock.mockRestore(); + } +}); + +it("rejects duplicate, malformed and future comparison anchors", async () => { + const viewResult = view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=bad"); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); + viewResult.unmount(); + view("?tab=Observers&observer=observer-a&compareWith=observer-b&compareUntil=" + (Date.now() + 86400000)); + expect(await screen.findByTestId("comparison-until")).toHaveTextContent("null"); +}); diff --git a/tests/features/observers/observer-navigation.test.ts b/tests/features/observers/observer-navigation.test.ts new file mode 100644 index 0000000..2c2ca87 --- /dev/null +++ b/tests/features/observers/observer-navigation.test.ts @@ -0,0 +1,10 @@ +import { expect, it } from "vitest"; +import { observerDestination, observerRange } from "../../../src/features/observers/observer-navigation"; +it("normalizes legacy analytics links without losing region",()=>{ + const p=observerDestination(new URLSearchParams("tab=Analytics&statsTab=observer&observerId=abc&range=30d&iata=YOW"),"abc"); + expect(p.get("tab")).toBe("Observers");expect(p.get("observer")).toBe("abc");expect(p.get("range")).toBe("30d");expect(p.get("iata")).toBe("YOW");expect(p.has("observerId")).toBe(false);expect(p.has("statsTab")).toBe(false); +}); +it("drops unrelated investigation selectors and bounds range",()=>{ + const p=observerDestination(new URLSearchParams("hash=abc&analyze=1&node=n&path=p"),"b"); + expect(p.toString()).toBe("tab=Observers&observer=b&range=7d");expect(observerRange("invalid")).toBe("7d"); +}); diff --git a/tests/features/packets/PacketEndpoints.test.tsx b/tests/features/packets/PacketEndpoints.test.tsx index 49d0ef2..1c75ded 100644 --- a/tests/features/packets/PacketEndpoints.test.tsx +++ b/tests/features/packets/PacketEndpoints.test.tsx @@ -1,5 +1,5 @@ -import { describe, it, expect } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { afterEach, describe, it, expect, vi } from "vitest"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints"; import type { LatestObserver, PacketSummary } from "../../../src/types/api"; @@ -13,6 +13,16 @@ const obs = (over: Partial = {}): LatestObserver => ({ id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over, }); +afterEach(() => vi.restoreAllMocks()); + +const ambiguousSource = { + confidence: "ambiguous" as const, + nodes: [ + { id: "source-a", name: "Alpha", publicKey: "aa010203" }, + { id: "source-b", name: "Beta", publicKey: "aa040506" }, + ], +}; + describe("PacketEndpoints", () => { it("renders a single n/a when there is no observer at all", () => { render(); @@ -84,4 +94,39 @@ describe("PacketEndpoints", () => { expect(screen.getByText("TRACE 2ca2a79c").className).toContain("text-text-muted"); expect(screen.queryByText("n/a")).not.toBeInTheDocument(); }); + + it("shows all ambiguous candidates on hover, with an additional-match count", () => { + render(); + const trigger = screen.getByText("Alpha +1").parentElement!; + fireEvent.mouseEnter(trigger); + const tip = screen.getByRole("tooltip"); + expect(within(tip).getByText("Alpha")).toBeInTheDocument(); + expect(within(tip).getByText("Beta")).toBeInTheDocument(); + expect(trigger).toHaveAttribute("title", "Alpha, Beta"); + expect(within(tip).queryByText(/SNR/)).not.toBeInTheDocument(); + }); + + it("keeps the containing packet row as the only keyboard button", () => { + render(); + expect(screen.getAllByRole("button")).toHaveLength(1); + const chip = screen.getByText("Alpha +1").parentElement!; + expect(chip).not.toHaveAttribute("tabindex"); + expect(chip).toHaveAttribute("title", "Alpha, Beta"); + }); + + it("opens all candidates on touch without activating the packet row", () => { + vi.spyOn(window, "matchMedia").mockImplementation((query) => ({ matches: false, media: query, onchange: null, addEventListener: vi.fn(), removeEventListener: vi.fn(), addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn() })); + const select = vi.fn(); + render(
); + fireEvent.click(screen.getByText("Alpha +1").parentElement!); + expect(within(screen.getByRole("tooltip")).getByText("Beta")).toBeInTheDocument(); + expect(select).not.toHaveBeenCalled(); + fireEvent.pointerDown(document.body); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + }); + + it("renders resolved endpoints without requiring physical path metadata", () => { + render(); + expect(screen.getByText("Source")).toBeInTheDocument(); + }); }); diff --git a/tests/features/packets/PacketTableRow.test.tsx b/tests/features/packets/PacketTableRow.test.tsx index 6833393..e537033 100644 --- a/tests/features/packets/PacketTableRow.test.tsx +++ b/tests/features/packets/PacketTableRow.test.tsx @@ -14,7 +14,7 @@ const node = (name: string): ResolvedHop => ({ nodes: [{ id: "n-1", name, publicKey: "aabbccdd" }], }); -// pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here. +// Physical path metadata is independent of the logical source/destination chips. const observer = ( over: { hopCount?: number; hashSize?: number } & Partial> = {}, ): LatestObserver => { diff --git a/tests/features/packets/path-summary.test.ts b/tests/features/packets/path-summary.test.ts index e17ed59..17720fc 100644 --- a/tests/features/packets/path-summary.test.ts +++ b/tests/features/packets/path-summary.test.ts @@ -102,20 +102,4 @@ describe("buildPathSummary", () => { expect(s.chips).toEqual([]); }); - it("carries resolved endpoints through", () => { - const s = buildPathSummary(pkt(obs({ - pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f", - resolvedSource: { confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Salish" }] }, - }))); - expect(s.source).toEqual({ kind: "node", label: "Salish", confidence: "high" }); - expect(s.destination).toBeNull(); - }); - - it("falls back to an unresolved-run chip for an endpoint with confidence none", () => { - const s = buildPathSummary(pkt(obs({ - pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f", - resolvedSource: { confidence: "none", nodes: [] }, - }))); - expect(s.source).toEqual({ kind: "unresolved-run", count: 1 }); - }); }); diff --git a/tests/features/stats/ObserverTab.test.tsx b/tests/features/stats/ObserverTab.test.tsx index a6006f8..2976015 100644 --- a/tests/features/stats/ObserverTab.test.tsx +++ b/tests/features/stats/ObserverTab.test.tsx @@ -1,8 +1,9 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; import { ObserverTab } from "../../../src/features/stats/ObserverTab"; +import i18n from "../../../src/i18n"; import { ApiError } from "../../../src/api/client"; import type { Observer } from "../../../src/features/observers/types"; import type { ObserverActivity, ObserverTelemetry, TelemetryPoint } from "../../../src/features/stats/types"; @@ -75,7 +76,10 @@ const point = (t: number, p: Partial): TelemetryPoint => ({ const telemetry: ObserverTelemetry = { range: "24h", interval: "1h", - points: [point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 })], + points: [ + point(0, { airtimeRxSecs: 100, airtimeTxSecs: 10, batteryMv: 4100 }), + point(H, { airtimeRxSecs: 154, airtimeTxSecs: 46, batteryMv: 4100 }), + ], }; const activity: ObserverActivity = { @@ -88,11 +92,17 @@ const activity: ObserverActivity = { function renderTab() { const qc = new QueryClient(); - const wrapper = ({ children }: { children: ReactNode }) => {children}; - return render( {}} wsManager={{} as WsManager} />, { wrapper }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + return render( + {}} wsManager={{} as WsManager} />, + { wrapper }, + ); } beforeEach(() => { + observer.brokers = []; telemetryResult.data = telemetry; telemetryResult.isLoading = false; telemetryResult.isError = false; @@ -105,7 +115,7 @@ beforeEach(() => { }); describe("ObserverTab", () => { - it("shows the latest reported RX and TX airtime as percent in the header", () => { + it("shows the latest reported RX and TX airtime as percent beside the airtime chart", () => { renderTab(); // +54 s RX and +36 s TX over the hour between the two reports expect(screen.getByText(/RX 1\.5%/)).toBeInTheDocument(); @@ -115,9 +125,9 @@ describe("ObserverTab", () => { it("renders the heard charts when the server has activity for the observer", () => { renderTab(); expect(screen.getByText(/channel busy/i)).toBeInTheDocument(); - expect(screen.getByText(/heard per 15 min/i)).toBeInTheDocument(); - expect(screen.getByText(/snr heard/i)).toBeInTheDocument(); - expect(screen.getByText(/payload types heard/i)).toBeInTheDocument(); + expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument(); + expect(screen.getByText(/received signal/i)).toBeInTheDocument(); + expect(screen.getByText(/packet-type mix/i)).toBeInTheDocument(); }); it("names the radio settings the busy percent assumes, in the same form as the header", () => { @@ -141,20 +151,74 @@ describe("ObserverTab", () => { expect(screen.queryByText(/RX 0%/)).not.toBeInTheDocument(); }); - it("shows the heard cards as loading while the previous selection's data is only a placeholder", () => { - activityResult.data = { ...activity, points: [] }; - activityResult.isPlaceholderData = true; - activityResult.dataUpdatedAt = 0; + it("keeps the last successful activity visible after a failed poll", () => { + activityResult.isError = true; + activityResult.error = new Error("temporary failure"); renderTab(); - expect(screen.getAllByText("Loading…").length).toBeGreaterThan(0); - expect(screen.queryByText(/no packets heard/i)).not.toBeInTheDocument(); - expect(screen.queryByText("910.525 MHz · SF7 · 62.5 kHz · CR 4/5")).toBeInTheDocument(); // header only + expect(screen.getByText(/recorded packets per 15 min/i)).toBeInTheDocument(); + expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); }); it("shows one empty card instead of flat charts when nothing was heard", () => { activityResult.data = { ...activity, payloadTypes: [], points: [] }; renderTab(); - expect(screen.getByText(/no packets heard/i)).toBeInTheDocument(); + expect(screen.getByText(/no packets recorded/i)).toBeInTheDocument(); expect(screen.queryByText(/channel busy/i)).not.toBeInTheDocument(); }); }); + +describe("Observer dashboard hierarchy", () => { + it("uses period metrics rather than the legacy presence counter", () => { + activityResult.data = { + ...activity, + windowStart: Date.now() - 86400000, + windowEnd: Date.now(), + generatedAt: Date.now(), + source: "raw", + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: Date.now() - 7200000, + lastCompleteHourEnd: Date.now() - 3600000, + latestRecordedAt: Date.now() - 60000, + }, + }; + renderTab(); + const cards = screen.getByRole("list", { name: "Observer metrics" }); + expect(within(cards).getByText("9")).toBeInTheDocument(); + expect(within(cards).getAllByRole("listitem")).toHaveLength(6); + expect(within(cards).queryByText("12")).not.toBeInTheDocument(); + expect( + screen.getByText("Recorded packets per 15 min").compareDocumentPosition(screen.getByText(/Airtime TX/)) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + it("does not invent zero packet metrics on an older server", () => { + renderTab(); + expect(screen.getByText(/Packet summary unavailable/)).toBeInTheDocument(); + }); + it("provides French monitoring labels", async () => { + await i18n.changeLanguage("fr"); + renderTab(); + expect(screen.getByText("Paquets enregistrés")).toBeInTheDocument(); + expect(screen.getByText("Détails de l’appareil")).toBeInTheDocument(); + }); +}); + +it("distinguishes packet arrivals from last deduplicated record and status freshness", () => { + const now = Date.now(); + observer.brokers = [{ name: "one", lastSeenAt: now, lastPacketAt: now }]; + activityResult.data = { + ...activity, + summary: { + recordedPackets: 9, + lastCompleteHour: 2, + lastCompleteHourStart: now - 7200000, + lastCompleteHourEnd: now - 3600000, + latestRecordedAt: now - 3600000, + }, + }; + renderTab(); + expect(screen.getByText("Recent packet traffic")).toBeInTheDocument(); +});