Open the observer dashboard from Inspect observer (#128)

This commit is contained in:
MrAlders0n authored and GitHub committed 2026-10-01 16:57:04 -04:00
1 parent e491a822c9
commit e3f98efea9
15 files changed
+175 -468

No files matched your search

+14 -7
View File
@@ -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: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverPage wsManager={wsManager} />,
Observers: <ObserverPage wsManager={wsManager} onAnalyzePacket={handleAnalyze} />,
Routes: <RouteTable onAnalyzePacket={viewPacket} onViewObserver={viewObserver} onViewNode={viewNode} />,
// 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) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onObserverDashboard={handleViewObserverStats} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)}
{panels.map((panel, index) => <InvestigationPanel key={panel.key} target={panel.target} inactive={index !== panels.length - 1} onClose={() => closePanel(index)} onOpen={openPanel} onViewObserver={viewObserver} onViewOnMap={activeTab === "Map" ? undefined : handleViewOnMap} />)}
</div>
</AppShell>
</RegionProvider>
+6 -11
View File
@@ -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<number | null>(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 <PacketPathMapModal detail={target.detail} initialSelectedKey={target.selectedKey} inactive={inactive} onClose={onClose} />;
return <ModalOverlay label={t(target.kind === "observer" ? "tabs.Observers" : target.kind === "node" ? "investigation.nodeDialog" : "investigation.packetDialog")} inactive={inactive} onClose={onClose}>
{target.kind === "observer" ? (
<ObserverDetailPanel observerId={target.id} onClose={onClose} onViewStats={onObserverDashboard} onAnalyzePacket={packet} />
) : target.kind === "node" ? (
<NodeDetailPanel nodeId={target.id} onClose={onClose} onViewObserver={observer} onViewNode={node} onAnalyzePacket={packet} onViewOnMap={onViewOnMap && ((lat, lng) => onViewOnMap(target.id, lat, lng))} />
return <ModalOverlay label={t(target.kind === "node" ? "investigation.nodeDialog" : "investigation.packetDialog")} inactive={inactive} onClose={onClose}>
{target.kind === "node" ? (
<NodeDetailPanel nodeId={target.id} onClose={onClose} onViewObserver={id => onViewObserver(id)} onViewNode={node} onAnalyzePacket={packet} onViewOnMap={onViewOnMap && ((lat, lng) => onViewOnMap(target.id, lat, lng))} />
) : (
<PacketAnalyzerDrawer
detail={detail} loading={isLoading} selectedObservationId={selected} onSelectObservation={setSelected}
requireSelectedObservation={target.observationId !== undefined} syncUrl={false}
onClose={onClose} onViewObserver={observer} onViewNode={node}
onClose={onClose} onViewObserver={(id, observationId) => onViewObserver(id, { hash: target.hash, observationId: observationId ?? selected ?? undefined })} onViewNode={node}
onViewPath={key => { if (detail) onOpen({ kind: "path", detail, selectedKey: key }); }}
/>
)}
@@ -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 (
<button type="button" onClick={onClick} className="w-full cursor-pointer rounded border border-border border-l-2 border-l-primary bg-bg-base px-3 py-2 text-left hover:bg-text-normal/3">
<span className="mb-1.5 flex items-center gap-2 text-[11px]">
<span className={`truncate font-mono font-semibold tracking-wider ${advert.nodeName ? "text-primary" : "italic text-text-dim"}`}>
{advert.nodeName ?? (advert.nodePublicKey ? formatHex(advert.nodePublicKey) : t("observerPage.unknownNode"))}
</span>
<IataChip>{advert.iata}</IataChip>
<Timestamp value={advert.heardAt} className="ml-auto font-mono text-[11px] text-text-dim" />
</span>
<span className="flex gap-5 font-mono text-xs">
<span className="flex flex-col"><span className="text-[10px] font-medium uppercase tracking-wider text-text-dim">SNR</span><span className={`font-medium ${signal}`}>{formatSnr(advert.snr)}</span></span>
<span className="flex flex-col"><span className="text-[10px] font-medium uppercase tracking-wider text-text-dim">RSSI</span><span className={`font-medium ${signal}`}>{advert.rssi ?? "—"}</span></span>
<span className="flex flex-col"><span className="text-[10px] font-medium uppercase tracking-wider text-text-dim">{t("observerPage.hops")}</span><span className="font-medium text-text-normal">{advert.hopCount ?? "—"}</span></span>
</span>
</button>
);
}
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 (
<details className="rounded-lg border border-border bg-bg-surface p-3.5">
<summary className="cursor-pointer font-mono text-[11px] font-semibold uppercase tracking-wider text-text-normal">{t("observerPage.advertsHeard")}</summary>
<div className="mt-2.5">
{isPending ? <p role="status" className="text-sm text-text-muted">{t("common.loading")}</p>
: adverts?.items.length ? (
<div className="grid gap-1.5 md:grid-cols-2">
{adverts.items.map(a => <AdvertRow key={a.id} advert={a} onClick={() => onAnalyzePacket(a.packetHash, a.id)} />)}
</div>
) : <p className="font-mono text-[13px] text-text-dim">{t("observerPage.noAdverts")}</p>}
</div>
</details>
);
}
@@ -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 (
<Tag
type={onClick ? "button" : undefined}
className={`w-full text-left bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary ${onClick ? "cursor-pointer hover:bg-text-normal/3" : ""}`}
onClick={onClick}
>
<span className="flex items-center gap-2 text-[11px] mb-1.5">
<span className={`font-mono font-semibold tracking-wider truncate ${advert.nodeName ? "text-primary" : "text-text-dim italic"}`}>
{advert.nodeName ?? (advert.nodePublicKey ? formatHex(advert.nodePublicKey) : "unknown")}
</span>
<IataChip>{advert.iata}</IataChip>
<Timestamp value={advert.heardAt} className="text-text-dim ml-auto font-mono text-[11px]" />
</span>
<span className="flex gap-5 font-mono text-xs">
<span className="flex flex-col">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">SNR</span>
<span className={`font-medium ${level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"}`}>{formatSnr(advert.snr)}</span>
</span>
<span className="flex flex-col">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">RSSI</span>
<span className={`font-medium ${level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal"}`}>{advert.rssi ?? "—"}</span>
</span>
<span className="flex flex-col">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">Hops</span>
<span className="font-medium text-text-normal">{advert.hopCount ?? "—"}</span>
</span>
</span>
</Tag>
);
}
// 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 (
<Section title="Radio">
<div className="font-mono text-[13px] text-text-muted">
{parts.join(" · ")}
</div>
{noiseFloor != null && (
<div className="font-mono text-[13px] mt-1">
<Field label="Noise floor" value={`${noiseFloor} dBm`} />
</div>
)}
</Section>
);
}
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 (
<DetailPanel
title="Observer Detail"
onClose={onClose}
closeLabel={t("investigation.closeObserver")}
headerAction={<CopyLinkButton preserveParams={["regions", "iata", "region"]} params={{ tab: "Observers", observer: observerId }} ariaLabel="Copy observer link" />}
isLoading={isLoading}
notFound={!observer}
notFoundLabel="Observer not found"
notFoundIcon={
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" className="text-border">
<circle cx="12" cy="8" r="4" stroke="currentColor" strokeWidth="1.2" />
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
</svg>
}
>
{observer && (
<>
<Section title="Summary" first>
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-xs font-semibold text-primary tracking-wider">
{observer.displayName ?? observer.id.slice(0, 8)}
</span>
<Badge variant={status === "online" ? "live" : "offline"}>
{status}
</Badge>
</div>
<div className="flex items-center gap-2 mb-1.5">
<div className="font-mono text-[13px] text-text-muted truncate min-w-0 flex-1" title={observer.publicKey}>
{observer.publicKey}
</div>
<CopyButton value={observer.publicKey} ariaLabel="Copy public key" className="shrink-0" />
</div>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label={t("observerPage.legacy")} value={observer.observationCount.toLocaleString()} />
</div>
<div className="flex flex-wrap items-center gap-2 mt-1.5">
{observer.observerType && <Badge variant="default">{observer.observerType}</Badge>}
<IataChip>{observer.iata}</IataChip>
{observer.scopes?.map((s) => (
<ScopeTag key={s}>{s}</ScopeTag>
))}
</div>
{onViewStats && (
<button
type="button"
onClick={() => 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"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" aria-hidden>
<path d="M4 20V4M4 20h16M8 16v-4M13 16V8M18 16v-6" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
{t("observerPage.open")}
</button>
)}
</Section>
{(observer.radioFreqMhz || observer.radioSf || observer.radioBwKhz || observer.radioCr) && (
<RadioSection observer={observer} noiseFloor={stats?.noise_floor} />
)}
{(observer.firmwareVersion || observer.softwareVersion || observer.hardwareModel) && (
<Section title="Firmware">
<div className="flex flex-col gap-0.5 font-mono text-[13px]">
{observer.firmwareVersion && <Field label="Version" value={observer.firmwareVersion} />}
{observer.softwareVersion && <Field label="Software" value={observer.softwareVersion} />}
{observer.hardwareModel && <Field label="Hardware" value={observer.hardwareModel} />}
</div>
</Section>
)}
<Section title="Status">
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px]">
{observer.batteryLevel != null && <Field label="Battery" value={formatBattery(observer.batteryLevel)} />}
{observer.uptimeSeconds != null && <Field label="Uptime" value={formatUptime(observer.uptimeSeconds)} />}
{stats?.queue_len != null && <Field label="Queue" value={stats.queue_len} />}
</div>
{observer.lastStatusAt && (
<div className="font-mono text-[13px] mt-1">
<Field label="Last status" value={<Timestamp value={observer.lastStatusAt} />} />
</div>
)}
</Section>
{stats && (stats.rx_air_secs != null || stats.tx_air_secs != null || stats.recv_errors != null) && (
<Section title="Airtime">
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px]">
{stats.rx_air_secs != null && <Field label="RX" value={formatAirtime(stats.rx_air_secs)} />}
{stats.tx_air_secs != null && <Field label="TX" value={formatAirtime(stats.tx_air_secs)} />}
</div>
{(stats.recv_errors != null || stats.errors != null) && (
<div className="flex flex-wrap gap-x-4 gap-y-0.5 font-mono text-[13px] mt-1">
{stats.recv_errors != null && <Field label="Recv errors" value={stats.recv_errors.toLocaleString()} />}
{stats.errors != null && <Field label="Errors" value={stats.errors.toLocaleString()} />}
</div>
)}
</Section>
)}
{observer.brokers.length > 0 && (
<Section title="Brokers">
<div className="flex flex-col gap-1.5">
{[...observer.brokers].sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })).map((b) => {
const variant = brokerStatusVariant(b.lastPacketAt);
return (
<div key={b.name} className="flex items-center gap-3">
<Badge variant={variant}>{b.name}</Badge>
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="Seen" value={<Timestamp value={b.lastSeenAt} />} />
<Field label="Packet" value={b.lastPacketAt ? <Timestamp value={b.lastPacketAt} /> : "—"} />
</div>
</div>
);
})}
</div>
</Section>
)}
<Section title="Adverts heard">
{adverts && adverts.items.length > 0 ? (
<div className="flex flex-col gap-1.5">
{adverts.items.map((a) => (
<AdvertRow
key={a.id}
advert={a}
onClick={onAnalyzePacket ? () => onAnalyzePacket(a.packetHash, a.id) : undefined}
/>
))}
</div>
) : (
<div className="font-mono text-[13px] text-text-dim">No adverts heard</div>
)}
</Section>
<Section title="Timestamps">
<div className="flex items-center gap-3 font-mono text-[13px]">
<Field label="First" value={<Timestamp value={observer.firstSeen} />} />
<span className="text-[6px] text-border" aria-hidden>·</span>
<Field label="Last" value={<Timestamp value={observer.lastSeen} />} />
</div>
</Section>
</>
)}
</DetailPanel>
);
}
+3 -1
View File
@@ -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 }) {
<button type="button" onClick={() => comparing ? closeCompare() : compare("")} className={`${ACTION_BUTTON_CLASS} text-center`}>{t(comparing ? "observerCompare.close" : "observerCompare.open")}</button>
</>} comparison={comparing ? { id: params.getAll("compareWith").length === 1 ? params.get("compareWith") ?? "" : "invalid", until, onSelect: compare, onRefresh: refreshCompare } : undefined} />
</Suspense>
<div className="mx-auto w-full max-w-[1600px] px-4 pb-4"><ObserverAdverts observerId={id} onAnalyzePacket={onAnalyzePacket} /></div>
</div>
</div> : <div className="hidden min-w-0 flex-1 md:flex"><EmptyState title={t("observerPage.choose")} /></div>}
</div>
@@ -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;
+2 -2
View File
@@ -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<string>; 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 && <button type="button" className={ACTION_BUTTON_CLASS} onClick={() => { onSelect(report.id); onViewObserver(report.observerId); }}>{t("investigation.observer")}</button>}
{onViewObserver && <button type="button" className={ACTION_BUTTON_CLASS} onClick={() => { onSelect(report.id); onViewObserver(report.observerId, report.id); }}>{t("investigation.observer")}</button>}
{canMap
? <button type="button" className={ACTION_BUTTON_CLASS} disabled={!onViewPath} onClick={() => { onSelect(report.id); onViewPath?.(key); }}>{t("investigation.map")}</button>
: <Tooltip wrap label={t("investigation.unmappable")}><button type="button" className={`${ACTION_BUTTON_CLASS} pointer-events-none`} disabled>{t("investigation.map")}<span className="sr-only">: {t("investigation.unmappable")}</span></button></Tooltip>}
+5 -1
View File
@@ -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…",
+5 -1
View File
@@ -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…",
+36 -28
View File
@@ -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 }) => <><h2>Observer {observerId}</h2><button onClick={onClose}>Close observer</button><button onClick={() => onViewStats(observerId)}>Open dashboard</button><button onClick={() => onAnalyzePacket("bb")}>Advert packet</button></> }));
vi.mock("../src/features/observers/ObserverAdverts", () => ({ ObserverAdverts: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string, id: number) => void }) => <button onClick={() => onAnalyzePacket("bb", 3)}>Advert packet</button> }));
vi.mock("../src/features/nodes/NodeDetailPanel", () => ({ NodeDetailPanel: ({ nodeId, onClose, onViewObserver }: { nodeId: string; onClose: () => void; onViewObserver: (id: string) => void }) => <><h2>Node {nodeId}</h2><button onClick={onClose}>Close node</button><button onClick={() => onViewObserver("o1")}>Node observer</button></> }));
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 }) => <><h2>Packet {detail?.packetHash} report {selectedObservationId}</h2><button onClick={onClose}>Close packet</button><button onClick={() => onViewObserver("o1")}>Packet observer</button><button onClick={() => onViewNode("n1")}>Packet node</button><button onClick={onViewPath}>Packet map</button><button onClick={() => onSelectObservation(8)}>Select report 8</button></> }));
@@ -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(<App />); 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(<App />); 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(<App />); 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(<App />); 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 node and packet underneath the observer when Escape is pressed", () => {
it("keeps the side drawer open when its observer is inspected", async () => {
window.history.replaceState({}, "", "/?tab=Routes&hash=aa&analyze=1");
render(<App />); click("Packet observer");
await dashboard();
expect(screen.getByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet aa");
});
it("closes stacked node windows when going to the dashboard", async () => {
render(<App />); click("Route packet"); click("Packet node"); click("Node observer");
escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Node n1");
await dashboard();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens an advert from the dashboard in the side drawer", async () => {
window.history.replaceState({}, "", "/?tab=Observers&observer=o1");
render(<App />); click("Advert packet");
expect(await screen.findByRole("heading", { name: /^Packet / })).toHaveTextContent("Packet bb");
expect(new URLSearchParams(window.location.search).get("observation")).toBe("3");
});
it("keeps the node and packet stack when Escape is pressed", () => {
render(<App />); 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 an already-open observer instead of making a cyclic panel chain", () => {
render(<App />); 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 the dashboard as a normal tab with every window left open", async () => {
render(<App />); 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");
escape(); expect(within(screen.getByRole("dialog")).getByRole("heading")).toHaveTextContent("Packet aa");
});
it("returns to the previous tab with browser Back", async () => {
render(<App />); click("Route observer"); click("Open dashboard");
render(<App />); 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(<App />); click("Route observer"); click("Open dashboard"); await screen.findByRole("heading", { level: 1 });
render(<App />); click("Route packet"); click("Packet node");
click("Change region");
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
-4
View File
@@ -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 }) => <button onClick={() => onViewStats(observerId)}>Open observer dashboard</button>,
}));
vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () => <h1>Observer dashboard</h1> }));
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();
+6 -14
View File
@@ -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 }) => <><button onClick={() => onViewObserver("observer")}>Node observer</button><button onClick={() => onAnalyzePacket("first")}>Node packet</button></> }));
vi.mock("../src/features/observers/ObserverDetailPanel", () => ({ ObserverDetailPanel: ({ onAnalyzePacket }: { onAnalyzePacket: (hash: string) => void }) => <button onClick={() => onAnalyzePacket("advert")}>Observer advert</button> }));
vi.mock("../src/features/observers/ObserverPage", () => ({ ObserverPage: () => <h1>Observer dashboard</h1> }));
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 }) => <ModalOverlay label="Packet" onClose={onClose} inactive={inactive}><h1>{detail?.packetHash}</h1><button onClick={() => onViewObserver("observer")}>Packet observer</button></ModalOverlay> }));
@@ -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(<App />);
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(<App />);
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");
});
});
@@ -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(<QueryClientProvider client={client}><ObserverAdverts observerId="obs-1" onAnalyzePacket={onAnalyzePacket} /></QueryClientProvider>);
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();
});
});
@@ -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 }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
);
render(<ObserverDetailPanel observerId="obs-1" onClose={vi.fn()} onAnalyzePacket={onAnalyzePacket} />, { 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);
});
});
@@ -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");
});