Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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") }));
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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); }}
/>
)}
Expand All @@ -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>
Expand Down
17 changes: 6 additions & 11 deletions src/features/InvestigationPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,13 @@ 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";
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 };

Expand All @@ -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 }); }}
/>
)}
Expand Down
51 changes: 51 additions & 0 deletions src/features/observers/ObserverAdverts.tsx
Original file line number Diff line number Diff line change
@@ -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>
);
}
Loading
Loading