Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 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
416f076
fix(navigation): preserve observer investigation context and keyboard…
n30nex Sep 27, 2026
c73339b
fix(navigation): preserve observer investigation context and keyboard…
n30nex Sep 27, 2026
ba79a85
fix(navigation): retain analytics context and expose observer chart l…
n30nex Sep 27, 2026
0fe2981
fix(navigation): preserve virtualized list geometry during dashboard …
n30nex Sep 27, 2026
e0c63dc
fix: address release review findings for #87
n30nex Sep 30, 2026
c0c1182
fix(navigation): retain observation callback dependencies
n30nex Sep 30, 2026
cad2003
feat(channels): show and filter recorded transport scopes
n30nex Sep 27, 2026
19bbbd1
fix: address release review findings for #89
n30nex Sep 30, 2026
04d56bc
fix(packets): contain route labels and scope badges
n30nex Sep 28, 2026
fa64057
fix: address release review findings for #92
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
9 changes: 8 additions & 1 deletion docs/translations.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,17 @@ 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.

The channel message panel also translates scope labels, evidence help, retained-history/key
availability states, pagination and packet-inspection actions. Scope names, hashes and message
bodies remain unchanged. The channel directory and its existing search controls are separate
translation follow-ups.

## Add a language

1. Copy `src/i18n/locales/en.json` to a language-tag filename such as `de.json`.
Expand Down
229 changes: 110 additions & 119 deletions src/App.tsx

Large diffs are not rendered by default.

27 changes: 20 additions & 7 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 @@ -142,14 +142,16 @@ export function getChannels(params?: { iatas?: string[]; limit?: number; cursor?
// a CursorPage so MessagePanel can load older history on demand via useInfiniteQuery.
export async function getChannelMessagesPage(
channelId: number,
params?: { iatas?: string[]; cursor?: number; limit?: number },
params?: { iatas?: string[]; cursor?: number; limit?: number; scope?: string },
): Promise<CursorPage<ChannelMessage>> {
const limit = params?.limit ?? DEFAULT_PAGE_SIZE;
const page = await request<{ items: ChannelMessage[] }>(`/channels/${channelId}/messages`, {
const page = await request<{ items: ChannelMessage[]; nextCursor?: number | null; hasMore?: boolean }>(`/channels/${channelId}/messages`, {
iatas: iatasParam(params?.iatas),
cursor: params?.cursor,
limit,
scope: params?.scope || undefined,
});
if (typeof page.hasMore === "boolean") return { items: page.items, nextCursor: page.nextCursor ?? null, hasMore: page.hasMore };
return toCursorPage(page.items, limit, (m) => m.id);
}

Expand Down Expand Up @@ -187,6 +189,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 +231,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 +381,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: 4 additions & 1 deletion src/components/CopyLinkButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,19 @@ export function CopyLinkButton({
label = "Copy Link",
copiedLabel = "Copied",
ariaLabel,
preserveParams,
}: {
params: Record<string, string> | (() => Record<string, string | null>);
label?: string;
copiedLabel?: string;
ariaLabel?: string;
preserveParams?: readonly string[];
}) {
const [copied, setCopied] = useState(false);

const handleCopy = useCallback(() => {
const url = new URL(window.location.href);
if (preserveParams) for (const key of [...url.searchParams.keys()]) if (!preserveParams.includes(key)) url.searchParams.delete(key);
const resolved = typeof params === "function" ? params() : params;
for (const [key, value] of Object.entries(resolved)) {
if (value === null) url.searchParams.delete(key);
Expand All @@ -28,7 +31,7 @@ export function CopyLinkButton({
navigator.clipboard.writeText(url.toString());
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [params]);
}, [params, preserveParams]);

return (
<button
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
7 changes: 4 additions & 3 deletions src/components/LoadingPill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ interface LoadingPillProps {
error?: boolean;
count: number; // rows loaded so far
noun: string; // plural entity name, e.g. "nodes" / "observers"
label?: string; // optional complete localized phrase
position?: string; // corner placement (the parent must be `relative`)
}

// Small floating status pill shared by the map and the entity tables: a muted "Loading … (N)" while
// pages stream in, a danger-toned message if a fetch fails. Renders nothing when idle.
export function LoadingPill({ loading, error, count, noun, position = "bottom-3 left-3" }: LoadingPillProps) {
export function LoadingPill({ loading, error, count, noun, label, position = "bottom-3 left-3" }: LoadingPillProps) {
if (!loading && !error) return null;
const tone = loading ? "text-text-muted" : "text-danger";
const dot = loading ? "bg-primary animate-pulse" : "bg-danger";
Expand All @@ -18,11 +19,11 @@ export function LoadingPill({ loading, error, count, noun, position = "bottom-3
className={`absolute ${position} z-10 flex items-center gap-2 px-2.5 py-1 bg-bg-surface border border-border-subtle rounded-md font-mono text-[11px] ${tone} shadow-lg`}
>
<span className={`size-1.5 rounded-full ${dot}`} aria-hidden />
{loading
{label ?? (loading
? `Loading ${noun}… (${count})`
: count > 0
? `Some ${noun} failed to load (${count} shown)`
: `Failed to load ${noun}`}
: `Failed to load ${noun}`)}
</div>
);
}
11 changes: 9 additions & 2 deletions src/components/ModalOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { useFocusTrap } from "../hooks/useFocusTrap";

// Right-anchored modal: dims the surface, focuses and traps keyboard focus within the panel, and
// closes on a backdrop click — only when the press started there, so releasing a text selection over
// the backdrop doesn't close. Escape is left to the caller (some callers gate it on nested state).
// the backdrop doesn't close. Escape closes only this active layer and stops before parent handlers.
// Pass `inactive` when another overlay is stacked on top so this one steps out of the modal/a11y
// path — it stops being an active modal and is hidden from assistive tech, while staying mounted so
// focus can return into it when the overlay above closes.
Expand All @@ -26,8 +26,15 @@ export function ModalOverlay({ label, onClose, inactive = false, children }: {
aria-label={label}
aria-hidden={inactive || undefined}
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === "Escape" && !inactive) {
e.preventDefault();
e.stopPropagation();
onClose();
}
}}
onMouseDown={(e) => { pressedBackdrop.current = e.target === e.currentTarget; }}
onClick={() => { if (pressedBackdrop.current) onClose(); }}
onClick={() => { if (!inactive && pressedBackdrop.current) onClose(); }}
>
<div className="h-full flex shadow-2xl" onClick={(e) => e.stopPropagation()}>
{children}
Expand Down
48 changes: 48 additions & 0 deletions src/features/InvestigationPanels.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
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 };

export interface Investigation {
key: string;
target: InvestigationTarget;
}

// Each panel stays mounted underneath the next one so its selection, scroll and trigger survive.
export function InvestigationPanel({ target, inactive, onClose, onOpen, onObserverDashboard }: {
target: InvestigationTarget; inactive: boolean; onClose: () => void;
onOpen: (target: InvestigationTarget) => void; onObserverDashboard: (id: string) => 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} />
) : (
<PacketAnalyzerDrawer
detail={detail} loading={isLoading} selectedObservationId={selected} onSelectObservation={setSelected}
requireSelectedObservation={target.observationId !== undefined} syncUrl={false}
onClose={onClose} onViewObserver={observer} onViewNode={node}
onViewPath={key => { if (detail) onOpen({ kind: "path", detail, selectedKey: key }); }}
/>
)}
</ModalOverlay>;
}
Loading
Loading