Skip to content
Closed
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
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
81 changes: 47 additions & 34 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,7 +170,10 @@ 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)
Expand All @@ -185,17 +193,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 +227,16 @@ function AppInner() {
if (isMobile) {
setSelectedObservationId(null);
setSelectedNodeId(null);
setSelectedObserverId(null);
setQuickObserverId(null);
}
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
// the analyzer is URL-backed, so its mobile close lives here rather than above
if (isMobile) next.delete("analyze");
if (tab !== "Observers") next.delete("observer");
// stats sub-state shouldn't haunt the URL on other tabs
if (tab !== "Analytics") {
if (tab !== "Analytics" && tab !== "Observers") {
next.delete("statsTab");
next.delete("observerId");
next.delete("range");
Expand All @@ -231,14 +249,14 @@ function AppInner() {
setSelectedNodeId(null);
setOverlayNodeId(null);
setOverlayPacketHash(null);
setSelectedObserverId(null);
setQuickObserverId(null);
setPathMapDetail(null);
}, []);

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

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

// Jump from an observer's detail panel to its telemetry on the Stats tab (Stats → Observer, preselected).
const handleViewObserverStats = useCallback(
(id: string) => {
setOverlayNodeId(null);
setOverlayPacketHash(null);
setQuickObserverId(null);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", "Analytics");
next.set("statsTab", "observer");
next.set("observerId", id);
return next;
return observerDestination(next, id);
});
},
[setSearchParams],
Expand All @@ -291,7 +302,7 @@ function AppInner() {
/>
),
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverTable wsManager={wsManager} selectedObserverId={selectedObserverId} onSelectObserver={handleSelectObserver} onAnalyzePacket={setOverlayPacketHash} onViewStats={handleViewObserverStats} />,
Observers: <ObserverPage wsManager={wsManager} />,
Routes: <RouteTable />,
// 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 @@ -324,19 +335,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,10 +356,10 @@ function AppInner() {
{overlayNodeId && (
<NodeDetailOverlay
nodeId={overlayNodeId}
inactive={!!quickObserverId || !!overlayPacketHash || !!pathMapDetail}
onClose={() => setOverlayNodeId(null)}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewNode={setOverlayNodeId}
/>
Expand All @@ -359,13 +370,15 @@ function AppInner() {
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
15 changes: 11 additions & 4 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,8 +225,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 +375,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
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
23 changes: 14 additions & 9 deletions src/features/map/PacketPathMapModal.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { PacketDetail } from "../../types/api";
import { ModalOverlay } from "../../components/ModalOverlay";
import { CloseButton } from "../../components/CloseButton";
Expand Down Expand Up @@ -34,11 +35,12 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: {
onClose: () => void;
initialSelectedKey?: string | null;
}) {
const { t } = useTranslation();
const paths = useMemo(() => buildPacketPaths(detail), [detail]);
const [selectedKey, setSelectedKey] = useState<string | null>(
// deep-link value that matches a known path isolates it; anything else (incl. "all") shows All
() => (initialSelectedKey && paths.some((p) => p.key === initialSelectedKey) ? initialSelectedKey : null),
() => initialSelectedKey && initialSelectedKey !== "all" ? initialSelectedKey : null,
);
const unavailable = selectedKey != null && !paths.some(p => p.key === selectedKey);
const styleId = useMemo(() => localStorage.getItem(MAP_STYLE_STORAGE_KEY) ?? DEFAULT_STYLE_ID, []);

useEffect(() => {
Expand All @@ -50,33 +52,36 @@ export function PacketPathMapModal({ detail, onClose, initialSelectedKey }: {
}, [onClose]);

return (
<ModalOverlay label="Packet path map" onClose={onClose}>
<ModalOverlay label={t("investigation.mapDialog")} onClose={onClose}>
<div className="h-full w-full md:w-[860px] md:max-w-[92vw] bg-bg-surface flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle shrink-0">
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">Packet Path</span>
<span className="text-[13px] font-mono font-medium text-text-dim uppercase tracking-wider">{t("investigation.mapTitle")}</span>
<div className="flex items-center gap-1.5">
<CopyLinkButton
params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null })}
ariaLabel="Copy path link"
params={() => ({ tab: "Packets", hash: detail.packetHash, path: selectedKey ?? "all", analyze: null, observation: null })}
label={t("investigation.copy")} copiedLabel={t("observerPage.copied")} ariaLabel={t("investigation.copyPath")}
/>
<CloseButton onClose={onClose} label="Close path map" className="-mr-1" />
<CloseButton onClose={onClose} label={t("investigation.closeMap")} className="-mr-1" />
</div>
</div>

<p className="border-b border-border-subtle px-3 py-2 text-xs text-text-muted">{t("investigation.mapNote")}</p>
{unavailable && <p role="status" className="px-3 py-2 text-sm text-warn">{t("investigation.unavailableMap")}</p>}
{paths.length === 0 && !unavailable && <p role="status" className="px-3 py-2 text-sm text-text-muted">{t("investigation.unmappable")}</p>}
<div className="flex-1 min-h-0 flex flex-col md:flex-row">
<div className="h-[55vh] max-md:shrink-0 md:h-auto md:flex-1 min-h-0 bg-bg-base">
<PacketPathMap paths={paths} selectedKey={selectedKey} styleId={styleId} />
</div>
<div className="md:w-[220px] md:border-l border-t md:border-t-0 border-border flex flex-col min-h-0 overflow-y-auto">
<div className="sticky top-0 bg-bg-surface z-10 border-b border-border-subtle">
<Row active={selectedKey === null} label="All paths" onClick={() => setSelectedKey(null)} />
<Row active={selectedKey === null} label={t("investigation.allPaths")} onClick={() => setSelectedKey(null)} />
</div>
{paths.map((p) => (
<Row
key={p.key}
active={selectedKey === p.key}
color={p.color}
label={p.label}
label={p.key === "trace" ? t("investigation.traceRoute") : p.label}
meta={formatPropagation(p.propagationMs)}
onClick={() => setSelectedKey(p.key)}
/>
Expand Down
Loading
Loading