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
51 changes: 27 additions & 24 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 @@ -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 Down Expand Up @@ -210,15 +218,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 +240,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 +260,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 +293,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 @@ -335,8 +337,7 @@ function AppInner() {
nodeId={selectedNodeId}
onClose={handleCloseNode}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewNode={setSelectedNodeId}
onAnalyzePacket={setOverlayPacketHash}
Expand All @@ -345,10 +346,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 +360,15 @@ function AppInner() {
loading={overlayPacketLoading}
onClose={() => setOverlayPacketHash(null)}
onViewObserver={(observerId) => {
handleTabChange("Observers");
setSelectedObserverId(observerId);
setQuickObserverId(observerId);
}}
onViewPath={() => { if (overlayPacketDetail) handleViewPath(overlayPacketDetail); }}
inactive={!!pathMapDetail}
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
9 changes: 5 additions & 4 deletions src/features/nodes/NodeDetailOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,23 @@ import { ModalOverlay } from "../../components/ModalOverlay";

// Node detail shown as a modal over the packet analyzer: the panel sits where the packet drawer is
// (right side) and the rest dims, so a user can peek at a path hop's node and close back to the packet.
export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode }: {
export function NodeDetailOverlay({ nodeId, onClose, onViewObserver, onViewNode, inactive = false }: {
nodeId: string;
inactive?: boolean;
onClose: () => void;
onViewObserver: (observerId: string) => void;
onViewNode?: (nodeId: string) => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
if (!inactive && e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
}, [onClose, inactive]);

return (
<ModalOverlay label="Node detail" onClose={onClose}>
<ModalOverlay label="Node detail" onClose={onClose} inactive={inactive}>
<NodeDetailPanel nodeId={nodeId} onClose={onClose} onViewObserver={onViewObserver} onViewNode={onViewNode} />
</ModalOverlay>
);
Expand Down
97 changes: 97 additions & 0 deletions src/features/observers/ObserverComparison.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { getObserverComparison } from "../../api/client";
import { formatBattery, formatUptime } from "../../lib/formatters";
import { ObserverPicker } from "./ObserverPicker";
import { Card, ChartCard } from "../stats/cards";
import { activityParamsFor, useObserver, useObserverActivity } from "../stats/useTelemetry";
import { useChartColors } from "../stats/chartTheme";
import { heardOption } from "../stats/chartOptions";
import { fillActivity, intervalToMs } from "../stats/transforms";
import { RANGE_MS, type ObserverActivity, type StatsRange } from "../stats/types";
import type { Observer } from "./types";

const uuid = /^(?!00000000-0000-0000-0000-000000000000$)[\da-f]{8}-[\da-f]{4}-[\da-f]{4}-[\da-f]{4}-[\da-f]{12}$/i;
function statusNoise(observer: Observer) {
const stats = observer.statusMetadata?.stats;
return stats && typeof stats === "object" && "noise_floor" in stats && typeof stats.noise_floor === "number" && Number.isFinite(stats.noise_floor) ? `${stats.noise_floor} dBm` : "—";
}

export function ObserverComparison({ observerA, activityA, range, observerBId, until, onSelect, onRefresh }: {
observerA: Observer; activityA?: ObserverActivity; range: StatsRange; observerBId: string; until: number | null;
onSelect: (id: string) => void; onRefresh: () => number;
}) {
const { t, i18n } = useTranslation(); const colors = useChartColors();
const valid = until != null && uuid.test(observerA.id) && uuid.test(observerBId) && observerA.id.toLowerCase() !== observerBId.toLowerCase();
const b = useObserver(valid ? observerBId : null);
const activityB = useObserverActivity(valid ? observerBId : null, range, until ?? undefined);
const dataB = activityB.isError ? undefined : activityB.data;
const intervalMs = intervalToMs(activityParamsFor(range).interval)!;
const end = until == null ? 0 : Math.floor(until / intervalMs) * intervalMs;
const start = end - RANGE_MS[range];
const aligned = [activityA, dataB].every(data => data?.summary && data.windowStart === start && data.windowEnd === end && intervalToMs(data.interval) === intervalMs);
const overlap = useQuery({
queryKey: ["observer-dashboard-overlap", observerA.id, observerBId, start, end],
queryFn: ({ signal }) => getObserverComparison(undefined, { observerA: observerA.id, observerB: observerBId, since: start, until: end }, signal),
enabled: valid && aligned && !!b.data && !b.isError,
staleTime: 30_000, refetchInterval: false, refetchOnWindowFocus: false, retry: false,
});
const option = useMemo(() => {
const window = { start, end };
const aPoints = activityA && aligned ? fillActivity(activityA.points, intervalMs, window) : [];
const bPoints = dataB && aligned ? fillActivity(dataB.points, intervalMs, window) : [];
return { ...heardOption(aPoints, colors, window), grid: { left: 48, right: 14, top: 32, bottom: 22 },
legend: { data: ["A", "B"], top: 0, textStyle: { color: colors.textNormal } },
series: [["A", aPoints, colors.primary], ["B", bPoints, colors.secondary]].map(([name, points, color]) => ({
name: name as string, type: "line" as const, symbol: "none", connectNulls: false,
data: (points as typeof aPoints).map(p => [p.t, p.observations]),
lineStyle: { width: 2, color: color as string }, itemStyle: { color: color as string },
})),
};
}, [activityA, dataB, aligned, intervalMs, start, end, colors]);
const date = (value: number | null | undefined) => value == null ? "—" : new Date(value).toLocaleString(i18n.resolvedLanguage, { timeZone: "UTC", dateStyle: "short", timeStyle: "short" });
const groups = overlap.data && !overlap.isError ? [
{ label: t("observerCompare.onlyA"), count: overlap.data.onlyA, color: colors.primary },
{ label: t("observerCompare.both"), count: overlap.data.both, color: colors.green },
{ label: t("observerCompare.onlyB"), count: overlap.data.onlyB, color: colors.secondary },
] : [];
const refresh = () => {
const nextUntil = onRefresh();
if (valid) {
void b.refetch();
if (nextUntil === until) {
void activityB.refetch();
if (aligned) void overlap.refetch();
}
}
};
const rows = aligned && b.data ? [
[t("observerPage.records"), activityA!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage), dataB!.summary!.recordedPackets.toLocaleString(i18n.resolvedLanguage)],
[t("observerPage.lastHour"), `${activityA!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(activityA!.summary!.lastCompleteHourStart)})`, `${dataB!.summary!.lastCompleteHour.toLocaleString(i18n.resolvedLanguage)} (${date(dataB!.summary!.lastCompleteHourStart)})`],
[t("observerPage.lastPacket"), date(activityA!.summary!.latestRecordedAt), date(dataB!.summary!.latestRecordedAt)],
[t("observerPage.battery"), observerA.batteryLevel == null ? "—" : formatBattery(observerA.batteryLevel), b.data.batteryLevel == null ? "—" : formatBattery(b.data.batteryLevel)],
[t("observerPage.uptime"), observerA.uptimeSeconds == null ? "—" : formatUptime(observerA.uptimeSeconds), b.data.uptimeSeconds == null ? "—" : formatUptime(b.data.uptimeSeconds)],
[t("observerPage.noise"), statusNoise(observerA), statusNoise(b.data)],
] : [];
return <section aria-label={t("observerCompare.title")} className="space-y-4 rounded-lg border border-primary-dim p-3">
<div className="flex flex-wrap items-center justify-between gap-2"><h2 className="text-lg font-semibold text-text-bright">{t("observerCompare.title")}</h2>
<button type="button" className="min-h-11 text-sm text-primary" onClick={refresh}>{t("observerCompare.refresh")}</button></div>
<p className="break-words text-sm text-text-normal">A: {observerA.displayName ?? observerA.id} {b.data && <>· B: {b.data.displayName ?? b.data.id}</>}</p>
<ObserverPicker id={observerBId} name={b.data?.displayName ?? t("observerCompare.choose")} excludeId={observerA.id} label={t("observerCompare.partner")} onSelect={onSelect} />
{until == null ? <p role="alert">{t("observerCompare.invalidTime")}</p> : !valid ? <p role={observerBId ? "alert" : "status"}>{t(observerBId ? "observerCompare.invalidObserver" : "observerCompare.choose")}</p> : b.isError || activityB.isError ? <p role="alert">{t("observerPage.loadFailed")}</p> : !activityA || activityB.isPending || b.isPending ? <p role="status">{t("common.loading")}</p> : !aligned ? <p role="status">{t("observerCompare.unavailable")}</p> : <>
<p className="text-xs leading-relaxed text-text-muted">{t("observerPage.window")}: {date(start)} – {date(end)}. {t("observerCompare.currentNote")}</p>
<div className="overflow-x-auto"><table aria-label={t("observerCompare.metrics")} className="w-full text-left text-sm tabular-nums"><thead><tr><th className="p-2">{t("observerCompare.metric")}</th><th className="p-2">A</th><th className="p-2">B</th></tr></thead><tbody>{rows.map(([label, a, bValue]) => <tr className="border-t border-border" key={label}><th scope="row" className="p-2 font-normal text-text-muted">{label}</th><td className="p-2">{a}</td><td className="p-2">{bValue}</td></tr>)}</tbody></table></div>
<ChartCard title={t("observerCompare.activity")} option={option} height={240} />
<Card title={t("observerCompare.overlap")}>
<p className="mb-3 text-xs leading-relaxed text-text-muted">{t("observerCompare.retention")}</p>
{overlap.isError ? <p role="alert">{t("common.loadFailed")}</p> : !overlap.data ? <p role="status">{t("common.loading")}</p> : <>
<p className="mb-3 text-lg font-semibold">{t("observerCompare.total", { count: overlap.data.totalPackets })}</p>
{overlap.data.totalPackets === 0 ? <p>{t("observerCompare.empty")}</p> : <div aria-hidden className="mb-3 flex h-5 overflow-hidden rounded">{groups.map(g => <div key={g.label} style={{ width: `${g.count / overlap.data!.totalPackets * 100}%`, background: g.color }} />)}</div>}
<dl className="grid grid-cols-3 gap-2 text-sm">{groups.map(g => <div key={g.label}><dt className="text-text-muted">{g.label}</dt><dd className="text-lg font-semibold" style={{ color: g.color }}>{g.count.toLocaleString(i18n.resolvedLanguage)}</dd></div>)}</dl>
<p className="mt-3 text-xs leading-relaxed text-text-muted">{t("observerCompare.definition")}</p>
</>}
</Card>
</>}
</section>;
}
Loading
Loading