Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e65e8e4
fix(packets): expose all endpoint candidates
n30nex Sep 26, 2026
f4092a4
fix: address release review findings for #75
n30nex Sep 30, 2026
e59e78b
feat(observers): open a unified shareable monitoring page
n30nex Sep 27, 2026
f27f13a
fix: address release review findings for #79
n30nex Sep 30, 2026
ab5bc0c
test: cover both close and stacked observer inspection
n30nex Sep 30, 2026
7d9fa69
feat(observers): prioritize activity and readable status cards
n30nex Sep 27, 2026
6852b4f
fix: address release review findings for #80
n30nex Sep 30, 2026
368ba18
fix: use the active locale when formatting observer noise
n30nex Sep 30, 2026
74f98a8
feat(observers): compare aligned dashboard activity and retained overlap
n30nex Sep 27, 2026
48b3ad1
fix: address release review findings for #81
n30nex Sep 30, 2026
4b207fa
fix(observers): refresh only the selected comparison window
n30nex Sep 30, 2026
4717f86
feat(packets): connect retained reports to observer and path investig…
n30nex Sep 27, 2026
dccf69e
fix(packets): keep selected evidence visible in compact investigations
n30nex Sep 27, 2026
5e6bf27
fix: address release review findings for #83
n30nex Sep 30, 2026
327ea06
fix(packets): select a report and open its analyzer atomically
n30nex Sep 30, 2026
9f00e88
feat(routes): connect retained reports to packet and observer inspection
n30nex Sep 27, 2026
93d26d5
fix(routes): keep phone reports visible and within the viewport
n30nex Sep 27, 2026
4030d52
fix: address release review findings for #85
n30nex Sep 30, 2026
ef8de07
fix(routes): remove obsolete region-key binding
n30nex Sep 30, 2026
7a82605
fix(routes): validate fixed windows against their opening time
n30nex Sep 30, 2026
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
4 changes: 3 additions & 1 deletion docs/translations.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ region/theme controls, connection/retry labels, lazy-page loading, shared
analytics section/range controls and chart states, and Traffic, RF / Signal, Paths & Hashes, Scopes and Clock Drift
(headings, legends, descriptive chart labels, explanations and exact tables).
Shared Timestamp labels and their relative tooltips also follow the selected
language. Other feature pages and general dialogs remain follow-up work. Measurement units
language. Packet reception-evidence controls and path-map controls explain retained
reports, intended TRACE routes, unavailable selections and ambiguous/missing map
locations in both languages. Packet bytes, identifiers and routing values stay unchanged. Other feature pages and general dialogs remain follow-up work. Measurement units
and existing UTC/date/number formatting, including automatic chart time labels,
are unchanged; selecting French does not mean every screen is translated yet.

Expand Down
94 changes: 56 additions & 38 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ import { PacketPathMapModal } from "./features/map/PacketPathMapModal";
import { NodeTable } from "./features/nodes/NodeTable";
import { NodeDetailPanel } from "./features/nodes/NodeDetailPanel";
import { NodeDetailOverlay } from "./features/nodes/NodeDetailOverlay";
import { ObserverTable } from "./features/observers/ObserverTable";
import { ObserverPage } from "./features/observers/ObserverPage";
import { ObserverDetailPanel } from "./features/observers/ObserverDetailPanel";
import { observerDestination } from "./features/observers/observer-navigation";
import { ModalOverlay } from "./components/ModalOverlay";
import { RouteTable } from "./features/routes/RouteTable";
import { TraceList } from "./features/traces/TraceList";
import { ChannelList } from "./features/channels/ChannelList";
Expand Down Expand Up @@ -114,11 +117,11 @@ export function PathLinkRestore({ initialPath, hash, analyzerDetail, onRestore }
const handledRef = useRef(false);

useEffect(() => {
const detail = analyzerDetail ?? pathLinkDetail;
const detail = analyzerDetail?.packetHash.toLowerCase() === hash?.toLowerCase() ? analyzerDetail : pathLinkDetail;
if (!initialPath || !detail || handledRef.current) return;
handledRef.current = true;
onRestore(detail, initialPath);
}, [initialPath, analyzerDetail, pathLinkDetail, onRestore]);
}, [initialPath, hash, analyzerDetail, pathLinkDetail, onRestore]);

return null;
}
Expand Down Expand Up @@ -149,13 +152,15 @@ function TabLoading({ tab }: { tab: string }) {
}

function AppInner() {
const { t } = useTranslation();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useIsMobile();
// The URL is the single source of truth for the active tab — back/forward just work, and an
// unknown ?tab value falls back to Packets instead of rendering a blank pane.
// "Stats" was renamed to "Analytics"; keep old ?tab=Stats links working.
const tabParam = searchParams.get("tab") === "Stats" ? "Analytics" : searchParams.get("tab");
const activeTab = ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets");
const legacyObserver = tabParam === "Analytics" && searchParams.get("statsTab") === "observer";
const activeTab = legacyObserver ? "Observers" : ENABLED_TABS.includes(tabParam ?? "") ? (tabParam as string) : (ENABLED_TABS[0] ?? "Packets");
// Resolve the starting selection once from URL → storage → legacy key (see computeInitialSelection).
const [initialSelection] = useState(() => computeInitialSelection(searchParams));

Expand All @@ -165,11 +170,16 @@ function AppInner() {
const [selectedObservationId, setSelectedObservationId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(() => searchParams.get("node"));
// lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts
const [selectedObserverId, setSelectedObserverId] = useState<string | null>(() => searchParams.get("observer"));
const [quickObserverId, setQuickObserverId] = useState<string | null>(null);
useEffect(() => {
if (legacyObserver) setSearchParams(observerDestination(searchParams, searchParams.get("observerId")), { replace: true });
}, [legacyObserver, searchParams, setSearchParams]);
// node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop)
const [overlayNodeId, setOverlayNodeId] = useState<string | null>(null);
// packet analyzer shown as a modal over the node panel (clicking a node's observation row)
const [overlayPacketHash, setOverlayPacketHash] = useState<string | null>(null);
const [overlayPacket, setOverlayPacket] = useState<{ hash: string; observationId?: number } | null>(null);
const overlayPacketHash = overlayPacket?.hash ?? null;
const setOverlayPacketHash = useCallback((hash: string | null, observationId?: number) => setOverlayPacket(hash ? { hash, observationId } : null), []);
// packet path popup shown as a modal over the analyzer drawer/overlay ("View path on map")
const [pathMapDetail, setPathMapDetail] = useState<PacketDetail | null>(null);
// Frozen together: the restore must fetch the hash the link asked for, even if the user clicks a
Expand All @@ -185,17 +195,26 @@ function AppInner() {
setPathMapInitialKey(key);
}, []);

// "View path on map" from anywhere that already holds a detail — no key, so the modal picks its own
const handleViewPath = useCallback((detail: PacketDetail) => {
// Reuse the fetched detail; a report action can isolate its observer's path.
const handleViewPath = useCallback((detail: PacketDetail, key?: string) => {
setPathMapDetail(detail);
setPathMapInitialKey(null);
setPathMapInitialKey(key ?? null);
}, []);

const handleAnalyze = useCallback((hash: string | null) => {
const selectObservation = useCallback((id: number | null) => {
setSelectedObservationId(id);
setSearchParams(previous => {
const next = new URLSearchParams(previous);
if (id == null) next.delete("observation"); else next.set("observation", String(id));
return next;
}, { replace: true });
}, [setSearchParams]);

const handleAnalyze = useCallback((hash: string | null, observationId?: number) => {
// No reset: observation ids are globally unique, so a pick inside an expanded row survives into the drawer.
setSearchParams((p) => {
const n = new URLSearchParams(p);
if (hash) { n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); }
if (hash) { if (n.get("hash") !== hash) n.delete("observation"); n.set("hash", hash); n.set("analyze", "1"); n.delete("path"); if (observationId != null) n.set("observation", String(observationId)); }
else n.delete("analyze");
return n;
}, { replace: true });
Expand All @@ -210,15 +229,17 @@ function AppInner() {
if (isMobile) {
setSelectedObservationId(null);
setSelectedNodeId(null);
setSelectedObserverId(null);
setQuickObserverId(null);
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
if (tab !== "Routes") for (const key of ["route", "routeIata", "routeRange", "routeSince", "routeUntil"]) next.delete(key);
// the analyzer is URL-backed, so its mobile close lives here rather than above
if (isMobile) next.delete("analyze");
if (tab !== "Observers") next.delete("observer");
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Analytics") {
if (tab !== "Analytics" && tab !== "Observers") {
next.delete("statsTab");
next.delete("observerId");
next.delete("range");
Expand All @@ -231,14 +252,14 @@ function AppInner() {
setSelectedNodeId(null);
setOverlayNodeId(null);
setOverlayPacketHash(null);
setSelectedObserverId(null);
setQuickObserverId(null);
setPathMapDetail(null);
}, []);
}, [setOverlayPacketHash]);

// Closing a detail panel drops its deep-link param so a reload can't reopen it (mirrors the packet
// analyzer's ?analyze cleanup). Selecting a different node/observer doesn't touch the URL — the panel's
// Copy Link button rebuilds a fresh link on demand.
const dropSelectionParam = useCallback((key: "node" | "observer") => {
const dropSelectionParam = useCallback((key: "node") => {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete(key);
Expand All @@ -251,25 +272,18 @@ function AppInner() {
dropSelectionParam("node");
}, [dropSelectionParam]);

const handleSelectObserver = useCallback((id: string | null) => {
setSelectedObserverId(id);
if (id === null) dropSelectionParam("observer");
}, [dropSelectionParam]);

// Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected).
const handleViewObserverStats = useCallback(
(id: string) => {
setOverlayNodeId(null);
setOverlayPacketHash(null);
setQuickObserverId(null);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", "Analytics");
next.set("statsTab", "observer");
next.set("observerId", id);
return next;
return observerDestination(next, id);
});
},
[setSearchParams],
[setSearchParams, setOverlayPacketHash],
);

useEffect(() => {
Expand All @@ -291,8 +305,8 @@ function AppInner() {
/>
),
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverTable wsManager={wsManager} selectedObserverId={selectedObserverId} onSelectObserver={handleSelectObserver} onAnalyzePacket={setOverlayPacketHash} onViewStats={handleViewObserverStats} />,
Routes: <RouteTable />,
Observers: <ObserverPage wsManager={wsManager} />,
Routes: <RouteTable onAnalyzePacket={setOverlayPacketHash} onViewObserver={setQuickObserverId} onViewNode={setOverlayNodeId} />,
// 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
Traces: <TraceList onAnalyze={setOverlayPacketHash} onViewNode={setOverlayNodeId} />,
Expand Down Expand Up @@ -324,19 +338,19 @@ function AppInner() {
detail={analyzerDetail}
loading={analyzerLoading}
selectedObservationId={selectedObservationId}
onSelectObservation={setSelectedObservationId}
onSelectObservation={selectObservation}
onClose={() => handleAnalyze(null)}
onViewNode={setOverlayNodeId}
onViewPath={() => { if (analyzerDetail) handleViewPath(analyzerDetail); }}
onViewObserver={setQuickObserverId}
onViewPath={(key) => { if (analyzerDetail) handleViewPath(analyzerDetail, key); }}
/>
)}
{(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && (
<NodeDetailPanel
nodeId={selectedNodeId}
onClose={handleCloseNode}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewNode={setSelectedNodeId}
onAnalyzePacket={setOverlayPacketHash}
Expand All @@ -345,27 +359,31 @@ function AppInner() {
{overlayNodeId && (
<NodeDetailOverlay
nodeId={overlayNodeId}
inactive={!!quickObserverId || !!overlayPacketHash || !!pathMapDetail}
onClose={() => setOverlayNodeId(null)}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewNode={setOverlayNodeId}
/>
)}
{overlayPacketHash && (
<PacketAnalyzerOverlay
key={`${overlayPacketHash}:${overlayPacket?.observationId ?? ""}`}
initialObservationId={overlayPacket?.observationId}
detail={overlayPacketDetail}
loading={overlayPacketLoading}
onClose={() => setOverlayPacketHash(null)}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }}
inactive={!!pathMapDetail}
onViewPath={(key) => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail, key); }}
inactive={!!pathMapDetail || !!quickObserverId}
/>
)}
{quickObserverId && <ModalOverlay label={t("tabs.Observers")} onClose={() => setQuickObserverId(null)}>
<ObserverDetailPanel observerId={quickObserverId} onClose={() => setQuickObserverId(null)} onViewStats={handleViewObserverStats} onAnalyzePacket={hash => { setQuickObserverId(null); setOverlayPacketHash(hash); }} />
</ModalOverlay>}
{pathMapDetail && (
<PacketPathMapModal
detail={pathMapDetail}
Expand Down
21 changes: 16 additions & 5 deletions src/api/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { API_BASE, DEFAULT_PAGE_SIZE } from "../lib/constants";
import { noteRateLimited, noteRequestOk, parseRetryAfter } from "./rate-limit";
import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api";
import type { CursorPage, PacketSummary, PacketDetail, IataCode, RegionSummary, Region, BrokerStatus, RouteEvidence, KnownRoute, CrossIATARoute, TraceTagSummary, TraceType, TraceDetail } from "../types/api";
import type { ChannelPage, ChannelMessage } from "../features/channels/types";
import type { ObserverSummary, Observer, AdvertObservation } from "../features/observers/types";
import type { NodeSummary, Node, NodeObservation, NodeNeighbor } from "../features/nodes/types";
Expand Down Expand Up @@ -187,6 +187,10 @@ export async function getKnownRoutesPage(
return toCursorPage(items, limit, (r) => r.lastSeen);
}

export function getRouteEvidence(iata: string, pathKey: string, params: { range?: string; since?: number; until?: number; pageCursor?: string; limit?: number }, signal?: AbortSignal): Promise<RouteEvidence> {
return request(`/routes/${encodeURIComponent(iata)}/${encodeURIComponent(pathKey)}/observations`, params, signal);
}

// Search known routes for a path between two node hash prefixes within a single IATA. All three params
// are required by the server.
export function searchKnownRoutes(iata: string, from: string, to: string): Promise<KnownRoute[]> {
Expand Down Expand Up @@ -225,8 +229,15 @@ export function getTraceDetail(tag: string): Promise<TraceDetail> {
return request(`/traces/${tag}`);
}

export function getObserver(observerId: string): Promise<Observer> {
return request(`/observers/${observerId}`);
export async function getObserver(observerId: string): Promise<Observer> {
const observer = await request<Observer>(`/observers/${observerId}`);
// Older servers marshal the stored JSON byte slice as base64 instead of an object.
let metadata: unknown = observer.statusMetadata;
if (typeof metadata === "string") {
try { metadata = JSON.parse(new TextDecoder().decode(Uint8Array.from(atob(metadata), c => c.charCodeAt(0)))); }
catch { metadata = undefined; }
}
return { ...observer, statusMetadata: metadata && typeof metadata === "object" && !Array.isArray(metadata) ? metadata as Record<string, unknown> : undefined };
}

export function getObserverAdverts(
Expand Down Expand Up @@ -368,8 +379,8 @@ export function getObserverTelemetry(
return request(`/observers/${observerId}/telemetry`, { range, interval, afterId });
}

export function getObserverActivity(observerId: string, range: string, interval: string): Promise<ObserverActivity> {
return request(`/observers/${observerId}/activity`, { range, interval });
export function getObserverActivity(observerId: string, range: string, interval: string, until?: number): Promise<ObserverActivity> {
return request(`/observers/${observerId}/activity`, { range, interval, until });
}

// Lets a caller hide a feature the server doesn't have rather than show it as failed.
Expand Down
5 changes: 3 additions & 2 deletions src/components/DetailPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ function MinimizeButton({ collapsed, onToggle }: { collapsed: boolean; onToggle:
interface DetailPanelProps {
title: string;
onClose: () => void;
closeLabel?: string;
// mobile-only: adds a minimize toggle that collapses to a bottom bar without deselecting, so the
// map underneath (and its neighbor lines) stays visible. No effect at md+ (the panel is a sidebar).
collapsible?: boolean;
Expand All @@ -49,7 +50,7 @@ interface DetailPanelProps {
children: ReactNode;
}

export function DetailPanel({ title, onClose, collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) {
export function DetailPanel({ title, onClose, closeLabel = "Close detail panel", collapsible, isLoading, notFound, notFoundIcon, notFoundLabel = "Not found", headerAction, children }: DetailPanelProps) {
const [collapsed, setCollapsed] = useState(false);
// collapse only touches the mobile overlay: shrink to a bottom bar and hide the body. The md:*
// classes below always win at desktop width, so a lingering collapsed state never hides the sidebar.
Expand All @@ -62,7 +63,7 @@ export function DetailPanel({ title, onClose, collapsible, isLoading, notFound,
{headerAction}
<div className="flex items-center gap-0.5">
{collapsible && <MinimizeButton collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />}
<CloseButton onClose={onClose} label="Close detail panel" />
<CloseButton onClose={onClose} label={closeLabel} />
</div>
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion src/features/map/PacketPathMap.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ export function PacketPathMap({ paths, selectedKey, styleId }: {
if (!map.getLayer(NODE_LABEL_LAYER)) {
map.addLayer({
id: NODE_LABEL_LAYER, type: "symbol", source: NODE_SOURCE,
layout: { "text-field": ["get", "label"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true },
layout: { "text-field": ["get", "label"], "text-font": ["Noto Sans Regular"], "text-size": 11, "text-offset": [0, 1.1], "text-anchor": "top", "text-optional": true },
paint: {
"text-color": paletteVar("--palette-text-bright", "#e5e7eb"),
"text-halo-color": paletteVar("--palette-bg-base", "#0a0a0a"),
Expand Down
Loading
Loading