diff --git a/src/App.tsx b/src/App.tsx index 6bf89693..c2f1e69b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -178,7 +178,6 @@ function AppInner() { }); }, [setPanels]); const viewNode = useCallback((id: string) => openPanel({ kind: "node", id }), [openPanel]); - const viewObserver = useCallback((id: string) => openPanel({ kind: "observer", id }), [openPanel]); const viewPacket = useCallback((hash: string, observationId?: number) => openPanel({ kind: "packet", hash, observationId }), [openPanel]); const handleViewPath = useCallback((detail: PacketDetail, key?: string) => openPanel({ kind: "path", detail, selectedKey: key }), [openPanel]); const [pathLink] = useState(() => ({ path: searchParams.get("path"), hash: searchParams.get("hash") })); @@ -272,9 +271,17 @@ function AppInner() { dropSelectionParam("node"); }, [dropSelectionParam, setSelectedNodeId]); - const handleViewObserverStats = useCallback((id: string) => { - navigate({ search: "?" + observerDestination(searchParams, id).toString() }); - }, [navigate, searchParams]); + // An observer opens its dashboard; a packet it was inspected from moves into the side drawer beside it. + const viewObserver = useCallback((id: string, packet?: { hash: string; observationId?: number }) => { + setPanels([]); + const next = observerDestination(searchParams, id); + if (packet) { + next.set("hash", packet.hash); next.set("analyze", "1"); next.delete("path"); + if (packet.observationId != null) next.set("observation", String(packet.observationId)); else next.delete("observation"); + } + if (isMobile) next.delete("analyze"); + navigate({ search: "?" + next.toString() }); + }, [navigate, searchParams, setPanels, isMobile]); useEffect(() => { // Region slugs can't be expanded yet (region details load async) — connect with the directly @@ -295,7 +302,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 @@ -331,7 +338,7 @@ function AppInner() { onSelectObservation={selectObservation} onClose={() => handleAnalyze(null)} onViewNode={viewNode} - onViewObserver={viewObserver} + onViewObserver={(id, observationId) => viewObserver(id, { hash: analyzerHash!, observationId: observationId ?? selectedObservationId ?? undefined })} onViewPath={(key) => { if (analyzerDetail) handleViewPath(analyzerDetail, key); }} /> )} @@ -345,7 +352,7 @@ function AppInner() { onViewOnMap={activeTab === "Map" ? undefined : (lat, lng) => handleViewOnMap(selectedNodeId, lat, lng)} /> )} - {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)} + {panels.map((panel, index) => closePanel(index)} onOpen={openPanel} onViewObserver={viewObserver} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)} diff --git a/src/features/InvestigationPanels.tsx b/src/features/InvestigationPanels.tsx index 66f7e380..5db6640d 100644 --- a/src/features/InvestigationPanels.tsx +++ b/src/features/InvestigationPanels.tsx @@ -2,7 +2,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; import { ModalOverlay } from "../components/ModalOverlay"; import { NodeDetailPanel } from "./nodes/NodeDetailPanel"; -import { ObserverDetailPanel } from "./observers/ObserverDetailPanel"; import { PacketAnalyzerDrawer } from "./packets/PacketAnalyzerDrawer"; import { usePacketDetail } from "./packets/usePacketDetail"; import { PacketPathMapModal } from "./map/PacketPathMapModal"; @@ -10,7 +9,6 @@ import type { PacketDetail } from "../types/api"; export type InvestigationTarget = | { kind: "node"; id: string } - | { kind: "observer"; id: string } | { kind: "packet"; hash: string; observationId?: number } | { kind: "path"; detail: PacketDetail; selectedKey?: string }; @@ -20,28 +18,25 @@ export interface Investigation { } // Each panel stays mounted underneath the next one so its selection, scroll and trigger survive. -export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard, onViewOnMap }: { +export function InvestigationPanel({ target, inactive, onClose, onOpen, onViewObserver, onViewOnMap }: { target: InvestigationTarget; inactive: boolean; onClose: () => void; - onOpen: (target: InvestigationTarget) => void; onObserverDashboard: (id: string) => void; + onOpen: (target: InvestigationTarget) => void; onViewObserver: (id: string, packet?: { hash: string; observationId?: number }) => void; onViewOnMap?: (nodeId: string, lat: number, lng: number) => void; }) { const { t } = useTranslation(); const { data: detail, isLoading } = usePacketDetail(target.kind === "packet" ? target.hash : null); const [selected, setSelected] = useState(target.kind === "packet" ? target.observationId ?? null : null); - const observer = (id: string) => onOpen({ kind: "observer", id }); const node = (id: string) => onOpen({ kind: "node", id }); const packet = (hash: string, observationId?: number) => onOpen({ kind: "packet", hash, observationId }); if (target.kind === "path") return ; - return - {target.kind === "observer" ? ( - - ) : target.kind === "node" ? ( - onViewOnMap(target.id, lat, lng))} /> + return + {target.kind === "node" ? ( + onViewObserver(id)} onViewNode={node} onAnalyzePacket={packet} onViewOnMap={onViewOnMap && ((lat, lng) => onViewOnMap(target.id, lat, lng))} /> ) : ( onViewObserver(id, { hash: target.hash, observationId: observationId ?? selected ?? undefined })} onViewNode={node} onViewPath={key => { if (detail) onOpen({ kind: "path", detail, selectedKey: key }); }} /> )} diff --git a/src/features/observers/ObserverAdverts.tsx b/src/features/observers/ObserverAdverts.tsx new file mode 100644 index 00000000..30fb0463 --- /dev/null +++ b/src/features/observers/ObserverAdverts.tsx @@ -0,0 +1,51 @@ +import { useQuery } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { getObserverAdverts } from "../../api/client"; +import { IataChip } from "../../components/IataChip"; +import { Timestamp } from "../../components/Timestamp"; +import { formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; +import type { AdvertObservation } from "./types"; + +function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick: () => void }) { + const { t } = useTranslation(); + const level = snrLevel(advert.snr); + const signal = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"; + return ( + + + + {advert.nodeName ?? (advert.nodePublicKey ? formatHex(advert.nodePublicKey) : t("observerPage.unknownNode"))} + + {advert.iata} + + + + SNR{formatSnr(advert.snr)} + RSSI{advert.rssi ?? "—"} + {t("observerPage.hops")}{advert.hopCount ?? "—"} + + + ); +} + +export function ObserverAdverts({ observerId, onAnalyzePacket }: { observerId: string; onAnalyzePacket: (hash: string, observationId: number) => void }) { + const { t } = useTranslation(); + const { data: adverts, isPending } = useQuery({ + queryKey: ["observer-adverts", observerId], + queryFn: () => getObserverAdverts(observerId, { limit: 50 }), + staleTime: 30_000, + }); + return ( + + {t("observerPage.advertsHeard")} + + {isPending ? {t("common.loading")} + : adverts?.items.length ? ( + + {adverts.items.map(a => onAnalyzePacket(a.packetHash, a.id)} />)} + + ) : {t("observerPage.noAdverts")}} + + + ); +} diff --git a/src/features/observers/ObserverDetailPanel.tsx b/src/features/observers/ObserverDetailPanel.tsx deleted file mode 100644 index f65dd869..00000000 --- a/src/features/observers/ObserverDetailPanel.tsx +++ /dev/null @@ -1,259 +0,0 @@ -import { useTranslation } from "react-i18next"; -import type { Observer, AdvertObservation } from "./types"; -import { useQuery } from "@tanstack/react-query"; -import { getObserver, getObserverAdverts } from "../../api/client"; -import { Badge } from "../../components/Badge"; -import { DetailPanel, Section, Field } from "../../components/DetailPanel"; -import { CopyButton } from "../../components/CopyButton"; -import { CopyLinkButton } from "../../components/CopyLinkButton"; -import { formatUptime, formatBattery, formatHex, formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters"; -import { Timestamp } from "../../components/Timestamp"; -import { useTick } from "../../hooks/useTick"; -import { deriveObserverStatus } from "./observer-status"; -import { getStats } from "./observer-stats"; -import type { BadgeVariant } from "../../components/badge-utils"; -import { IataChip } from "../../components/IataChip"; -import { ScopeTag } from "../../components/ScopeTag"; - -function AdvertRow({ advert, onClick }: { advert: AdvertObservation; onClick?: () => void }) { - const level = snrLevel(advert.snr); - const Tag = onClick ? "button" : "div"; - return ( - - - - {advert.nodeName ?? (advert.nodePublicKey ? formatHex(advert.nodePublicKey) : "unknown")} - - {advert.iata} - - - - - SNR - {formatSnr(advert.snr)} - - - RSSI - {advert.rssi ?? "—"} - - - Hops - {advert.hopCount ?? "—"} - - - - ); -} - -// broker freshness badge: <5m = live, <30m = stale -function brokerStatusVariant(lastPacketAt: number | null): BadgeVariant { - if (!lastPacketAt) return "offline"; - const ageMs = Date.now() - lastPacketAt; - return ageMs < 5 * 60_000 ? "live" : ageMs < 30 * 60_000 ? "stale" : "offline"; -} - -function formatAirtime(secs: number): string { - if (secs < 60) return `${secs}s`; - if (secs < 3600) return `${Math.floor(secs / 60)}m ${secs % 60}s`; - const h = Math.floor(secs / 3600); - const m = Math.floor((secs % 3600) / 60); - return `${h}h ${m}m`; -} - -function RadioSection({ observer, noiseFloor }: { observer: Observer; noiseFloor?: number | null }) { - const parts = [ - observer.radioFreqMhz && `${observer.radioFreqMhz} MHz`, - observer.radioSf && `SF${observer.radioSf}`, - observer.radioBwKhz && `${observer.radioBwKhz} kHz`, - observer.radioCr && `CR 4/${observer.radioCr}`, - ].filter(Boolean) as string[]; - - return ( - - - {parts.join(" · ")} - - {noiseFloor != null && ( - - - - )} - - ); -} - -interface ObserverDetailPanelProps { - observerId: string; - onClose: () => void; - onAnalyzePacket?: (hash: string, observationId: number) => void; - onViewStats?: (observerId: string) => void; -} - -export function ObserverDetailPanel({ observerId, onClose, onAnalyzePacket, onViewStats }: ObserverDetailPanelProps) { - const { t } = useTranslation(); - const { data: observer, isLoading } = useQuery({ - queryKey: ["observer", observerId], - queryFn: () => getObserver(observerId), - staleTime: 30_000, - }); - - const { data: adverts } = useQuery({ - queryKey: ["observer-adverts", observerId], - queryFn: () => getObserverAdverts(observerId, { limit: 50 }), - staleTime: 30_000, - }); - - useTick(); // re-derive the status badge as lastStatusAt ages - const stats = observer ? getStats(observer.statusMetadata) : null; - const status = observer ? deriveObserverStatus(observer) : null; - - return ( - } - isLoading={isLoading} - notFound={!observer} - notFoundLabel="Observer not found" - notFoundIcon={ - - - - - } - > - {observer && ( - <> - - - - {observer.displayName ?? observer.id.slice(0, 8)} - - - {status} - - - - - {observer.publicKey} - - - - - - - - {observer.observerType && {observer.observerType}} - {observer.iata} - {observer.scopes?.map((s) => ( - {s} - ))} - - {onViewStats && ( - onViewStats(observer.id)} - className="mt-2.5 flex w-full items-center justify-center gap-1.5 rounded border border-border bg-bg-base px-2.5 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal transition-colors cursor-pointer hover:border-primary hover:text-primary" - > - - - - {t("observerPage.open")} - - )} - - - {(observer.radioFreqMhz || observer.radioSf || observer.radioBwKhz || observer.radioCr) && ( - - )} - - {(observer.firmwareVersion || observer.softwareVersion || observer.hardwareModel) && ( - - - {observer.firmwareVersion && } - {observer.softwareVersion && } - {observer.hardwareModel && } - - - )} - - - - {observer.batteryLevel != null && } - {observer.uptimeSeconds != null && } - {stats?.queue_len != null && } - - {observer.lastStatusAt && ( - - } /> - - )} - - - {stats && (stats.rx_air_secs != null || stats.tx_air_secs != null || stats.recv_errors != null) && ( - - - {stats.rx_air_secs != null && } - {stats.tx_air_secs != null && } - - {(stats.recv_errors != null || stats.errors != null) && ( - - {stats.recv_errors != null && } - {stats.errors != null && } - - )} - - )} - - {observer.brokers.length > 0 && ( - - - {[...observer.brokers].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).map((b) => { - const variant = brokerStatusVariant(b.lastPacketAt); - return ( - - {b.name} - - } /> - : "—"} /> - - - ); - })} - - - )} - - - {adverts && adverts.items.length > 0 ? ( - - {adverts.items.map((a) => ( - onAnalyzePacket(a.packetHash, a.id) : undefined} - /> - ))} - - ) : ( - No adverts heard - )} - - - - - } /> - · - } /> - - - > - )} - - ); -} diff --git a/src/features/observers/ObserverPage.tsx b/src/features/observers/ObserverPage.tsx index 2cc9a59f..f1cd0c42 100644 --- a/src/features/observers/ObserverPage.tsx +++ b/src/features/observers/ObserverPage.tsx @@ -12,6 +12,7 @@ import { useScopes } from "../../hooks/useScopes"; import { useTick } from "../../hooks/useTick"; import { ObserverFilterBar } from "./ObserverFilterBar"; import { ObserverSidebar } from "./ObserverSidebar"; +import { ObserverAdverts } from "./ObserverAdverts"; import { filterObservers } from "./observer-filter"; import { useObserverDirectory } from "./useObserverDirectory"; import { observerDestination, observerRange } from "./observer-navigation"; @@ -19,7 +20,7 @@ import { Segmented } from "../stats/Segmented"; import type { WsManager } from "../../api/ws-manager"; const ObserverTab = lazy(() => import("../stats/ObserverTab").then(m => ({ default: m.ObserverTab }))); -export function ObserverPage({ wsManager }: { wsManager: WsManager }) { +export function ObserverPage({ wsManager, onAnalyzePacket }: { wsManager: WsManager; onAnalyzePacket: (hash: string, observationId: number) => void }) { const [params, setParams] = useSearchParams(); const { t } = useTranslation(); const now = useTick(60_000); @@ -100,6 +101,7 @@ export function ObserverPage({ wsManager }: { wsManager: WsManager }) { comparing ? closeCompare() : compare("")} className={`${ACTION_BUTTON_CLASS} text-center`}>{t(comparing ? "observerCompare.close" : "observerCompare.open")} >} comparison={comparing ? { id: params.getAll("compareWith").length === 1 ? params.get("compareWith") ?? "" : "invalid", until, onSelect: compare, onRefresh: refreshCompare } : undefined} /> + : } diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 199bb111..d4e9dea5 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -36,7 +36,7 @@ interface PacketAnalyzerDrawerProps { onSelectObservation?: (id: number) => void; onViewNode?: (nodeId: string) => void; onViewPath?: (key?: string) => void; - onViewObserver?: (id: string) => void; + onViewObserver?: (id: string, observationId?: number) => void; loading?: boolean; requireSelectedObservation?: boolean; syncUrl?: boolean; diff --git a/src/features/packets/PacketInvestigation.tsx b/src/features/packets/PacketInvestigation.tsx index ed84bb54..dc330459 100644 --- a/src/features/packets/PacketInvestigation.tsx +++ b/src/features/packets/PacketInvestigation.tsx @@ -13,7 +13,7 @@ const PAGE = 3; // Every retained reception, grouped by the path bytes it carried; each report is a selectable signal card. export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserver, onViewPath, onViewNode, mappedKeys, observerCount, defaultOpen = false }: { detail: PacketDetail; selectedId: number | null; onSelect: (id: number) => void; - onViewObserver?: (id: string) => void; onViewPath?: (key?: string) => void; onViewNode?: (id: string) => void; + onViewObserver?: (id: string, observationId: number) => void; onViewPath?: (key?: string) => void; onViewNode?: (id: string) => void; mappedKeys: Set; observerCount: number; defaultOpen?: boolean; }) { const { t } = useTranslation(); @@ -53,7 +53,7 @@ export function PacketInvestigation({ detail, selectedId, onSelect, onViewObserv isTrace={isTrace} selectLabel={`${t("investigation.inspect")}: ${name}`} actions={<> - {onViewObserver && { onSelect(report.id); onViewObserver(report.observerId); }}>{t("investigation.observer")}} + {onViewObserver && { onSelect(report.id); onViewObserver(report.observerId, report.id); }}>{t("investigation.observer")}} {canMap ? { onSelect(report.id); onViewPath?.(key); }}>{t("investigation.map")} : {t("investigation.map")}: {t("investigation.unmappable")}} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 79cef77a..7b170233 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -324,7 +324,11 @@ "sortName": "Name", "deviceInfo": "Device", "brokers": "Brokers", - "copyLinkShort": "Copy link" + "copyLinkShort": "Copy link", + "advertsHeard": "Adverts heard", + "noAdverts": "No adverts heard", + "unknownNode": "unknown", + "hops": "Hops" }, "observerCompare": { "open": "Compare with…", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 692b93e4..7a2e2657 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -329,7 +329,11 @@ "sortName": "Nom", "deviceInfo": "Appareil", "brokers": "Courtiers", - "copyLinkShort": "Copier le lien" + "copyLinkShort": "Copier le lien", + "advertsHeard": "Annonces reçues", + "noAdverts": "Aucune annonce reçue", + "unknownNode": "inconnu", + "hops": "Sauts" }, "observerCompare": { "open": "Comparer avec…", diff --git a/tests/App.observerInvestigation.test.tsx b/tests/App.observerInvestigation.test.tsx index 2b1f0e34..9170adfe 100644 --- a/tests/App.observerInvestigation.test.tsx +++ b/tests/App.observerInvestigation.test.tsx @@ -36,7 +36,7 @@ vi.mock("../src/features/stats/TalkersTab", () => ({ TalkersTab: () => null })); vi.mock("../src/features/stats/ClockDriftTab", () => ({ ClockDriftTab: () => null })); vi.mock("../src/features/stats/CompareObserversTab", () => ({ CompareObserversTab: () => null })); vi.mock("../src/features/stats/NeighbourGraphTab", () => ({ NeighbourGraphTab: () => null })); -vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ observerId, onClose, onViewStats, onAnalyzePacket }: { observerId: string; onClose: () => void; onViewStats: (id: string) => void; onAnalyzePacket: (hash: string) => void }) => <>Observer {observerId}Close observer onViewStats(observerId)}>Open dashboard onAnalyzePacket("bb")}>Advert packet> })); +vi.mock("../src/features/observers/ObserverAdverts", () => ({ ObserverAdverts: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string, id: number) => void }) => onAnalyzePacket("bb", 3)}>Advert packet })); vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ nodeId, onClose, onViewObserver }: { nodeId: string; onClose: () => void; onViewObserver: (id: string) => void }) => <>Node {nodeId}Close node onViewObserver("o1")}>Node observer> })); vi.mock("../src/features/packets/usePacketDetail", () => ({ usePacketDetail: (hash: string | null) => ({ data: hash ? { packetHash: hash, observations: [], header: { payloadType: 1 } } as unknown as PacketDetail : undefined, isLoading: false }) })); vi.mock("../src/features/packets/PacketAnalyzerDrawer", () => ({ PacketAnalyzerDrawer: ({ detail, onClose, onViewObserver, onViewNode, onViewPath, selectedObservationId, onSelectObservation }: { detail: PacketDetail; onClose: () => void; onViewObserver: (id: string) => void; onViewNode: (id: string) => void; onViewPath: () => void; selectedObservationId: number | null; onSelectObservation: (id: number) => void }) => <>Packet {detail?.packetHash} report {selectedObservationId}Close packet onViewObserver("o1")}>Packet observer onViewNode("n1")}>Packet nodePacket map onSelectObservation(8)}>Select report 8> })); @@ -52,41 +52,49 @@ const escape = () => fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escap const browserBack = async () => { await act(async () => { window.history.back(); }); await waitFor(() => expect(window.location.search).toContain("tab=Routes")); }; describe("observer investigation windows", () => { - it("dismisses only the observer with Escape and returns keyboard focus", () => { - render(); click("Route observer"); escape(); + const dashboard = async (id = "o1") => { + expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(`Dashboard ${id}`); + const params = new URLSearchParams(window.location.search); + expect(params.get("tab")).toBe("Observers"); expect(params.get("observer")).toBe(id); + }; + it("opens the dashboard straight from the route panel", async () => { + render(); click("Route observer"); + await dashboard(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Route observer" })).toHaveFocus(); }); - it("orders route packet -> observer -> advert packet and preserves the earlier report", () => { - render(); click("Route packet"); click("Select report 8"); click("Packet observer"); click("Advert packet"); - expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet bb"); - escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1"); - expect(screen.getByRole("button", { name: "Advert packet" })).toHaveFocus(); - escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa report 8"); - escape(); expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + it("moves a stacked packet into the side drawer beside the dashboard, keeping its report", async () => { + render(); click("Route packet"); click("Select report 8"); click("Packet observer"); + await dashboard(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa"); + const params = new URLSearchParams(window.location.search); + expect(params.get("hash")).toBe("aa"); expect(params.get("analyze")).toBe("1"); expect(params.get("observation")).toBe("8"); + }); + it("keeps the side drawer open when its observer is inspected", async () => { + window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1"); + render(); click("Packet observer"); + await dashboard(); + expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa"); }); - it("keeps the node and packet underneath the observer when Escape is pressed", () => { + it("closes stacked node windows when going to the dashboard", async () => { render(); click("Route packet"); click("Packet node"); click("Node observer"); - escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Node n1"); - escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); - click("Packet map"); escape(); - expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); + await dashboard(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); }); - it("returns to an already-open observer instead of making a cyclic panel chain", () => { - render(); click("Route packet"); click("Select report 8"); click("Packet observer"); click("Advert packet"); click("Packet observer"); - expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1"); - escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa report 8"); + it("opens an advert from the dashboard in the side drawer", async () => { + window.history.replaceState({}, "", "/?tab=Observers&observer=o1"); + render(); click("Advert packet"); + expect(await screen.findByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet bb"); + expect(new URLSearchParams(window.location.search).get("observation")).toBe("3"); }); - it("opens the dashboard as a normal tab with every window left open", async () => { - render(); click("Route packet"); click("Packet observer"); click("Open dashboard"); - expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent("Dashboard o1 7d"); - expect(window.location.search).toContain("tab=Observers&observer=o1"); - expect(screen.queryByRole("button", { name: "Back to Routes" })).not.toBeInTheDocument(); - expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Observer o1"); + it("keeps the node and packet stack when Escape is pressed", () => { + render(); click("Route packet"); click("Packet node"); escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); + click("Packet map"); escape(); + expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa"); }); it("returns to the previous tab with browser Back", async () => { - render(); click("Route observer"); click("Open dashboard"); + render(); click("Route observer"); await screen.findByRole("heading", { level: 1 }); await browserBack(); expect(screen.getByTestId("route-origin")).toBeInTheDocument(); }); @@ -126,7 +134,7 @@ describe("observer investigation windows", () => { expect(window.location.search).not.toContain("statsTab"); }); it("closes the stacked windows when the region changes", async () => { - render(); click("Route observer"); click("Open dashboard"); await screen.findByRole("heading", { level: 1 }); + render(); click("Route packet"); click("Packet node"); click("Change region"); await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument()); }); diff --git a/tests/App.packetUrlContract.test.tsx b/tests/App.packetUrlContract.test.tsx index dc3fbbd3..480db88e 100644 --- a/tests/App.packetUrlContract.test.tsx +++ b/tests/App.packetUrlContract.test.tsx @@ -33,9 +33,6 @@ vi.mock("../src/api/client", () => ({ getChannelMessagesPage: async () => ({ items: [], nextCursor: null, hasMore: false }), })); -vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ - ObserverDetailPanel: ({ observerId, onViewStats }: { observerId: string; onViewStats: (id: string) => void }) => onViewStats(observerId)}>Open observer dashboard, -})); vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () => Observer dashboard })); const packet: PacketSummary = { @@ -148,7 +145,6 @@ describe("Packets deep links", () => { const section = await screen.findByRole("region", { name: "Observations" }); expect(within(section).getByText("No path entries").closest("details")).toHaveAttribute("open"); fireEvent.click(within(section).getByRole("button", { name: "Inspect observer" })); - fireEvent.click(await screen.findByRole("button", { name: "Open observer dashboard" })); expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument(); expect(new URLSearchParams(window.location.search).get("observation")).toBe("1"); expect(screen.getByTestId("packet-analyzer-drawer")).toBeInTheDocument(); diff --git a/tests/App.quickObserver.test.tsx b/tests/App.quickObserver.test.tsx index 99bc5f57..cfcd9119 100644 --- a/tests/App.quickObserver.test.tsx +++ b/tests/App.quickObserver.test.tsx @@ -10,7 +10,7 @@ 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 }) => <> onViewObserver("observer")}>Node observer onAnalyzePacket("first")}>Node packet> })); -vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string) => void }) => onAnalyzePacket("advert")}>Observer advert })); +vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () => Observer dashboard })); 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} onViewObserver("observer")}>Packet observer })); @@ -22,20 +22,12 @@ beforeEach(() => { }); afterEach(() => vi.unstubAllGlobals()); -describe("quick observer investigation", () => { - it("closes the quick observer before opening its advert analyzer", () => { +describe("inspecting an observer from a window", () => { + it("leaves the node window for the observer dashboard", async () => { 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); + expect(await screen.findByRole("heading", { name: "Observer dashboard" })).toBeInTheDocument(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + expect(new URLSearchParams(window.location.search).get("observer")).toBe("observer"); }); }); diff --git a/tests/features/observers/ObserverAdverts.test.tsx b/tests/features/observers/ObserverAdverts.test.tsx new file mode 100644 index 00000000..e0a124ca --- /dev/null +++ b/tests/features/observers/ObserverAdverts.test.tsx @@ -0,0 +1,45 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { ObserverAdverts } from "../../../src/features/observers/ObserverAdverts"; +import { getObserverAdverts } from "../../../src/api/client"; +import type { AdvertObservation } from "../../../src/features/observers/types"; + +vi.mock("../../../src/api/client", () => ({ getObserverAdverts: vi.fn() })); +const mockGetObserverAdverts = vi.mocked(getObserverAdverts); + +function advert(id: number, nodeName: string): AdvertObservation { + return { id, packetHash: `hash-${id}`, payloadType: 4, payloadTypeName: "ADVERT", iata: "YVR", heardAt: 1000, snr: 7.5, rssi: -90, hopCount: 0, nodeName, nodePublicKey: "1122334455" }; +} + +function renderAdverts(onAnalyzePacket = vi.fn()) { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render(); + return { onAnalyzePacket }; +} + +beforeEach(() => mockGetObserverAdverts.mockReset()); + +describe("ObserverAdverts", () => { + it("lists adverts heard by the observer", async () => { + mockGetObserverAdverts.mockResolvedValue({ items: [advert(1, "Node Alpha"), advert(2, "Node Beta")], nextCursor: null, hasMore: false }); + renderAdverts(); + expect(screen.getByText("Adverts heard")).toBeInTheDocument(); + expect(await screen.findByText("Node Alpha")).toBeInTheDocument(); + expect(screen.getByText("Node Beta")).toBeInTheDocument(); + expect(mockGetObserverAdverts).toHaveBeenCalledWith("obs-1", { limit: 50 }); + }); + + it("analyzes the packet when an advert is clicked", async () => { + mockGetObserverAdverts.mockResolvedValue({ items: [advert(1, "Node Alpha")], nextCursor: null, hasMore: false }); + const { onAnalyzePacket } = renderAdverts(); + fireEvent.click(await screen.findByRole("button", { name: /Node Alpha/ })); + expect(onAnalyzePacket).toHaveBeenCalledWith("hash-1", 1); + }); + + it("says when no adverts were heard", async () => { + mockGetObserverAdverts.mockResolvedValue({ items: [], nextCursor: null, hasMore: false }); + renderAdverts(); + expect(await screen.findByText("No adverts heard")).toBeInTheDocument(); + }); +}); diff --git a/tests/features/observers/ObserverDetailPanel.test.tsx b/tests/features/observers/ObserverDetailPanel.test.tsx deleted file mode 100644 index 2b710a2d..00000000 --- a/tests/features/observers/ObserverDetailPanel.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; -import { render, screen, fireEvent } from "@testing-library/react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import type { ReactNode } from "react"; -import { ObserverDetailPanel } from "../../../src/features/observers/ObserverDetailPanel"; -import { getObserver, getObserverAdverts } from "../../../src/api/client"; -import type { Observer, AdvertObservation } from "../../../src/features/observers/types"; - -vi.mock("../../../src/api/client", () => ({ - getObserver: vi.fn(), - getObserverAdverts: vi.fn(), -})); - -const mockGetObserver = vi.mocked(getObserver); -const mockGetObserverAdverts = vi.mocked(getObserverAdverts); - -const observer: Observer = { - id: "obs-1", - iata: "YVR", - status: "online", - publicKey: "aabbccddeeff", - firstSeen: 1, - lastSeen: 2, - observationCount: 10, - brokers: [], -}; - -function advert(id: number, nodeName: string): AdvertObservation { - return { - id, - packetHash: `hash-${id}`, - payloadType: 4, - payloadTypeName: "ADVERT", - iata: "YVR", - heardAt: 1000, - snr: 7.5, - rssi: -90, - hopCount: 0, - nodeName, - nodePublicKey: "1122334455", - }; -} - -function renderPanel(onAnalyzePacket = vi.fn()) { - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - const wrapper = ({ children }: { children: ReactNode }) => ( - {children} - ); - render(, { wrapper }); - return { onAnalyzePacket }; -} - -beforeEach(() => { - mockGetObserver.mockReset(); - mockGetObserverAdverts.mockReset(); - mockGetObserver.mockResolvedValue(observer); - mockGetObserverAdverts.mockResolvedValue({ items: [], nextCursor: null, hasMore: false }); -}); - -describe("ObserverDetailPanel status", () => { - it("renders offline when lastStatusAt is 10 minutes old even if the API status says online", async () => { - mockGetObserver.mockResolvedValue({ ...observer, status: "online", lastStatusAt: Date.now() - 10 * 60_000 }); - - renderPanel(); - - expect(await screen.findByText("offline")).toBeInTheDocument(); - expect(screen.queryByText("online")).not.toBeInTheDocument(); - }); - - it("renders online when lastStatusAt is fresh", async () => { - mockGetObserver.mockResolvedValue({ ...observer, status: "online", lastStatusAt: Date.now() - 60_000 }); - - renderPanel(); - - expect(await screen.findByText("online")).toBeInTheDocument(); - }); -}); - -describe("ObserverDetailPanel copy link", () => { - const writeText = vi.fn(); - - beforeEach(() => { - Object.defineProperty(navigator, "clipboard", { value: { writeText }, writable: true, configurable: true }); - writeText.mockClear(); - }); - - afterEach(() => window.history.replaceState({}, "", "/")); - - it("copies a link scoped to the observer tab, dropping foreign params", async () => { - window.history.replaceState({}, "", "/?tab=Packets&hash=abc&analyze=1&observation=7&iata=YVR"); - - renderPanel(); - - await screen.findByText("online"); - fireEvent.click(screen.getByRole("button", { name: "Copy observer link" })); - - const url = new URL(writeText.mock.calls[0][0]); - expect(url.searchParams.get("tab")).toBe("Observers"); - expect(url.searchParams.get("observer")).toBe("obs-1"); - expect(url.searchParams.get("iata")).toBe("YVR"); - expect(url.searchParams.has("hash")).toBe(false); - expect(url.searchParams.has("analyze")).toBe(false); - expect(url.searchParams.has("observation")).toBe(false); - }); -}); - -describe("ObserverDetailPanel adverts", () => { - it("lists adverts heard by the observer", async () => { - mockGetObserverAdverts.mockResolvedValue({ - items: [advert(1, "Node Alpha"), advert(2, "Node Beta")], - nextCursor: null, - hasMore: false, - }); - - renderPanel(); - - expect(await screen.findByText("Adverts heard")).toBeInTheDocument(); - expect(await screen.findByText("Node Alpha")).toBeInTheDocument(); - expect(screen.getByText("Node Beta")).toBeInTheDocument(); - expect(mockGetObserverAdverts).toHaveBeenCalledWith("obs-1", { limit: 50 }); - }); - - it("analyzes the packet when an advert row is clicked", async () => { - mockGetObserverAdverts.mockResolvedValue({ - items: [advert(1, "Node Alpha")], - nextCursor: null, - hasMore: false, - }); - - const { onAnalyzePacket } = renderPanel(); - - const row = await screen.findByRole("button", { name: /Node Alpha/ }); - expect(row.tagName).toBe("BUTTON"); - row.focus(); - fireEvent.click(row); - expect(onAnalyzePacket).toHaveBeenCalledWith("hash-1", 1); - }); -}); diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 50054147..ed8126f9 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -56,7 +56,7 @@ describe("packet reception evidence", () => { expect(within(beta).getByText("SNR")).toBeInTheDocument(); expect(screen.queryByText(/^Observations \(/)).not.toBeInTheDocument(); for (const name of ["Inspect observer", "Map path"]) expect(within(beta).getByRole("button", { name })).toHaveClass("border", "border-border", "text-primary"); - fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta"); + fireEvent.click(within(beta).getByRole("button", { name: "Inspect observer" })); expect(onViewObserver).toHaveBeenCalledWith("beta", 2); fireEvent.click(within(beta).getByRole("button", { name: "Map path" })); expect(onViewPath).toHaveBeenCalledWith("beta"); expect(screen.getByTestId("search")).toHaveTextContent("observation=2"); expect(screen.getByTestId("search")).toHaveTextContent("q=keep"); });
{t("common.loading")}
{t("observerPage.noAdverts")}