feat: packet analyzer overlay, shared modals with focus trap

- usePackets pulls a bigger first page (INITIAL_PACKET_PAGE_SIZE)
- ModalOverlay + useFocusTrap, used by NodeDetailOverlay and PacketAnalyzerOverlay
- PathData popovers for path resolutions; ObserverIcon and Tooltip
- tests for packet structure and the focus trap; tidied types in api.ts/enums.ts
This commit is contained in:
MrAlders0n committed 2026-06-04 20:22:28 -04:00
1 parent d726e2659d
commit 4b8109262b
31 files changed
+1177 -285

No files matched your search

+58 -5
View File
@@ -7,8 +7,10 @@ import { AppShell } from "./components/AppShell";
import { SplashScreen } from "./components/SplashScreen";
import { PacketList } from "./features/packets/PacketList";
import { PacketAnalyzerDrawer } from "./features/packets/PacketAnalyzerDrawer";
import { PacketAnalyzerOverlay } from "./features/packets/PacketAnalyzerOverlay";
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 { ChannelList } from "./features/channels/ChannelList";
import { StatsOverview } from "./features/stats/StatsOverview";
@@ -108,18 +110,31 @@ function AppInner() {
const [analyzerHash, setAnalyzerHash] = useState<string | null>(() => searchParams.get("hash"));
const [selectedObservationId, setSelectedObservationId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
// lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts
const [selectedObserverId, setSelectedObserverId] = useState<string | null>(null);
// 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 [drawerOpen, setDrawerOpen] = useState(() => {
const stored = localStorage.getItem(DRAWER_STORAGE_KEY);
return stored === null ? true : stored === "true";
});
const { data: analyzerDetail } = useQuery({
const { data: analyzerDetail, isLoading: analyzerLoading } = useQuery({
queryKey: ["packet-detail", analyzerHash],
queryFn: () => getPacketDetail(analyzerHash!),
enabled: !!analyzerHash,
staleTime: Infinity,
});
const { data: overlayPacketDetail, isLoading: overlayPacketLoading } = useQuery({
queryKey: ["packet-detail", overlayPacketHash],
queryFn: () => getPacketDetail(overlayPacketHash!),
enabled: !!overlayPacketHash,
staleTime: Infinity,
});
const handleAnalyze = useCallback((hash: string | null) => {
setAnalyzerHash(hash);
setSelectedObservationId(null);
@@ -139,6 +154,8 @@ function AppInner() {
const handleTabChange = (tab: string) => {
setActiveTab(tab);
setOverlayNodeId(null);
setOverlayPacketHash(null);
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("tab", tab);
@@ -146,7 +163,12 @@ function AppInner() {
});
};
const clearSelection = useCallback(() => setSelectedNodeId(null), []);
const clearSelection = useCallback(() => {
setSelectedNodeId(null);
setOverlayNodeId(null);
setOverlayPacketHash(null);
setSelectedObserverId(null);
}, []);
useEffect(() => {
wsManager.connect({ iatas: initialRegion === "*" ? undefined : [initialRegion], events: ["packetObservation", "channelMessage", "observerStatus", "nodeUpdate"] });
@@ -157,7 +179,7 @@ function AppInner() {
const tabContent: Record<string, React.ReactNode> = {
Packets: <PacketList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Nodes: <NodeTable wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
Observers: <ObserverTable wsManager={wsManager} />,
Observers: <ObserverTable wsManager={wsManager} selectedObserverId={selectedObserverId} onSelectObserver={setSelectedObserverId} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Stats: <StatsOverview />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
@@ -169,7 +191,7 @@ function AppInner() {
<RegionUrlSync />
<SelectionResetOnRegion onRegionChange={clearSelection} />
<AppShell activeTab={activeTab} onTabChange={handleTabChange} wsManager={wsManager}>
<div className="flex flex-1 min-h-0">
<div className="relative flex flex-1 min-h-0">
<div key={activeTab} className="flex flex-1 min-h-0 fade-in">
<Suspense fallback={<EmptyState title={activeTab} subtitle="Loading…" />}>
{tabContent[activeTab]}
@@ -178,14 +200,45 @@ function AppInner() {
{analyzerHash && (activeTab === "Packets" || activeTab === "Channels") && (
<PacketAnalyzerDrawer
detail={analyzerDetail}
loading={analyzerLoading}
selectedObservationId={selectedObservationId}
onSelectObservation={setSelectedObservationId}
open={drawerOpen}
onToggle={handleToggleDrawer}
onViewNode={setOverlayNodeId}
/>
)}
{(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && (
<NodeDetailPanel nodeId={selectedNodeId} onClose={() => setSelectedNodeId(null)} />
<NodeDetailPanel
nodeId={selectedNodeId}
onClose={() => setSelectedNodeId(null)}
onViewObserver={(observerId) => {
setSelectedObserverId(observerId);
handleTabChange("Observers");
}}
onAnalyzePacket={setOverlayPacketHash}
/>
)}
{overlayNodeId && (
<NodeDetailOverlay
nodeId={overlayNodeId}
onClose={() => setOverlayNodeId(null)}
onViewObserver={(observerId) => {
setSelectedObserverId(observerId);
handleTabChange("Observers");
}}
/>
)}
{overlayPacketHash && (
<PacketAnalyzerOverlay
detail={overlayPacketDetail}
loading={overlayPacketLoading}
onClose={() => setOverlayPacketHash(null)}
onViewObserver={(observerId) => {
setSelectedObserverId(observerId);
handleTabChange("Observers");
}}
/>
)}
</div>
</AppShell>
+4 -4
View File
@@ -105,8 +105,8 @@ export async function getNodes(
iata?: string;
type?: string;
name?: string;
supportsMultibytePaths?: boolean;
supportsMultibyteTraces?: boolean;
supportsMultibytePaths?: "true" | "false";
supportsMultibyteTraces?: "true" | "false";
limit?: number;
},
): Promise<NodeSummary[]> {
@@ -114,8 +114,8 @@ export async function getNodes(
iata: params?.iata,
typeName: params?.type,
name: params?.name,
supportsMultibytePaths: params?.supportsMultibytePaths ? "true" : undefined,
supportsMultibyteTraces: params?.supportsMultibyteTraces ? "true" : undefined,
supportsMultibytePaths: params?.supportsMultibytePaths,
supportsMultibyteTraces: params?.supportsMultibyteTraces,
limit: params?.limit,
});
return page.items;
+58 -7
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useMemo, useState, type ReactNode } from "react";
import { EmptyState } from "./EmptyState";
import { SkeletonRows } from "./SkeletonRows";
@@ -6,8 +6,11 @@ export interface Column<T> {
header: string;
cell: (row: T) => ReactNode;
className?: string; // extra classes applied to the <td>
sortValue?: (row: T) => string | number | null | undefined; // column is sortable when present
}
type SortDirection = "asc" | "desc";
interface DataTableProps<T> {
columns: Column<T>[];
rows: T[] | undefined;
@@ -16,11 +19,42 @@ interface DataTableProps<T> {
onSelect: (key: string | null) => void;
isLoading?: boolean;
emptyLabel: string;
defaultSort?: { header: string; direction?: SortDirection };
}
// selectable, sticky-header list table shared by the entity tabs (observers, nodes, …)
export function DataTable<T>({ columns, rows, rowKey, selectedKey, onSelect, isLoading, emptyLabel }: DataTableProps<T>) {
export function DataTable<T>({ columns, rows, rowKey, selectedKey, onSelect, isLoading, emptyLabel, defaultSort }: DataTableProps<T>) {
const [sort, setSort] = useState<{ header: string; direction: SortDirection }>(() => ({
header: defaultSort?.header ?? "",
direction: defaultSort?.direction ?? "asc",
}));
function toggleSort(header: string) {
setSort((prev) =>
prev.header === header
? { header, direction: prev.direction === "asc" ? "desc" : "asc" }
: { header, direction: "asc" },
);
}
const sortedRows = useMemo(() => {
if (!rows) return rows;
const col = columns.find((c) => c.header === sort.header && c.sortValue);
if (!col?.sortValue) return rows;
const getValue = col.sortValue;
const dir = sort.direction === "asc" ? 1 : -1;
return [...rows].sort((a, b) => {
const av = getValue(a);
const bv = getValue(b);
const aEmpty = av == null || av === "";
const bEmpty = bv == null || bv === "";
if (aEmpty || bEmpty) return aEmpty === bEmpty ? 0 : aEmpty ? 1 : -1; // empties sink to the bottom
if (typeof av === "number" && typeof bv === "number") return (av - bv) * dir;
return String(av).localeCompare(String(bv), undefined, { numeric: true }) * dir;
});
}, [rows, columns, sort]);
if (isLoading) {
return (
<div className="flex-1 overflow-y-auto">
@@ -31,17 +65,34 @@ export function DataTable<T>({ columns, rows, rowKey, selectedKey, onSelect, isL
return (
<div className="flex-1 overflow-y-auto">
{rows && rows.length > 0 ? (
{sortedRows && sortedRows.length > 0 ? (
<table className="w-full text-xs font-mono">
<thead className="sticky top-0 bg-bg-surface z-10">
<tr className="text-text-muted text-[11px] uppercase tracking-wider border-b border-border">
{columns.map((col) => (
<th key={col.header} className="text-left px-4 py-2 font-medium">{col.header}</th>
))}
{columns.map((col) => {
if (!col.sortValue) {
return <th key={col.header} className="text-left px-4 py-2 font-medium">{col.header}</th>;
}
const active = sort.header === col.header;
return (
<th key={col.header} className="text-left px-4 py-2 font-medium">
<button
type="button"
onClick={() => toggleSort(col.header)}
className="flex items-center gap-1 cursor-pointer hover:text-text-normal transition-colors"
>
{col.header}
<span className={active ? "text-text-normal" : "text-text-dim/40"}>
{active ? (sort.direction === "asc" ? "▲" : "▼") : "▲"}
</span>
</button>
</th>
);
})}
</tr>
</thead>
<tbody>
{rows.map((row) => {
{sortedRows.map((row) => {
const key = rowKey(row);
const isSelected = key === selectedKey;
return (
-24
View File
@@ -1,24 +0,0 @@
import type { Observation } from "../types/api";
import { VARIANT_CLASSES } from "./badge-utils";
export function HopBadge({ hop }: { hop: Observation["resolvedPath"][number] }) {
if (hop.confidence === "high" && hop.node) {
return (
<span className={`px-1.5 py-0.5 rounded-sm border font-medium ${VARIANT_CLASSES.advert}`}>
{hop.node.name ?? hop.node.publicKey.slice(0, 8)}
</span>
);
}
if (hop.confidence === "low" && hop.node) {
return (
<span className={`px-1.5 py-0.5 rounded-sm border font-medium ${VARIANT_CLASSES.trace}`}>
{hop.node.name ?? hop.node.publicKey.slice(0, 8)}
</span>
);
}
return (
<span className={`px-1.5 py-0.5 rounded-sm border font-medium ${VARIANT_CLASSES.default}`}>
??
</span>
);
}
+34
View File
@@ -0,0 +1,34 @@
import { useRef, type ReactNode } from "react";
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. Escape is left to the caller (some callers gate it on nested state).
// 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.
export function ModalOverlay({ label, onClose, inactive = false, children }: {
label: string;
onClose: () => void;
inactive?: boolean;
children: ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref);
return (
<div
ref={ref}
className="absolute inset-0 z-40 flex justify-end bg-black/50 fade-in"
role="dialog"
aria-modal={!inactive}
aria-label={label}
aria-hidden={inactive || undefined}
tabIndex={-1}
onClick={onClose}
>
<div className="h-full flex shadow-2xl" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
// Observer "eye" glyph. Matches the map's observer pip (OBSERVER_COLOR #c79bff in
// features/map/node-icons.ts) — kept as a literal hex so the nodes bundle doesn't pull in map code.
export function ObserverIcon({ className = "" }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" aria-hidden="true" className={className}>
<path d="M2 12C5 6.5 19 6.5 22 12 19 17.5 5 17.5 2 12Z" stroke="#c79bff" strokeWidth="2" strokeLinejoin="round" />
<circle cx="12" cy="12" r="3" fill="#c79bff" />
</svg>
);
}
+56
View File
@@ -0,0 +1,56 @@
import { useState, useRef, useEffect, useLayoutEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
// Lightweight hover tooltip. Renders into <body> with fixed positioning so it's never clipped by a
// scrolling/overflow parent (e.g. the data tables), and sits centered just above the target.
export function Tooltip({ label, children, className = "" }: { label: ReactNode; children: ReactNode; className?: string }) {
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
const [anchor, setAnchor] = useState<DOMRect | null>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
function show() {
const rect = ref.current?.getBoundingClientRect();
if (rect) setAnchor(rect);
}
const hide = () => setAnchor(null);
// A fixed-position tip would detach from its target on scroll/resize, so close it rather than track.
useEffect(() => {
if (!anchor) return;
window.addEventListener("scroll", hide, true);
window.addEventListener("resize", hide);
return () => {
window.removeEventListener("scroll", hide, true);
window.removeEventListener("resize", hide);
};
}, [anchor]);
// Center above the target, then clamp on-screen and flip below if it would clip the top edge.
useLayoutEffect(() => {
if (!anchor || !tipRef.current) return;
const { offsetWidth: w, offsetHeight: h } = tipRef.current;
const m = 6;
const left = Math.min(Math.max(anchor.left + anchor.width / 2 - w / 2, m), window.innerWidth - w - m);
const above = anchor.top - m - h;
setPos({ left, top: above >= m ? above : anchor.bottom + m });
}, [anchor]);
return (
<span ref={ref} onMouseEnter={show} onMouseLeave={hide} className={`inline-flex ${className}`}>
{children}
{anchor &&
createPortal(
<span
ref={tipRef}
role="tooltip"
style={{ left: pos.left, top: pos.top }}
className="fixed z-50 pointer-events-none whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg"
>
{label}
</span>,
document.body,
)}
</span>
);
}
+19 -10
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useEffect, useState, useCallback } from "react";
import { useMemo, useRef, useLayoutEffect, useState, useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { getChannelMessages } from "../../api/client";
import { Badge } from "../../components/Badge";
@@ -71,9 +71,10 @@ export function MessagePanel({ channel, heardCounts, iata, region, onAnalyze }:
);
const bottomRef = useRef<HTMLDivElement>(null);
const prevCount = useRef(0);
const [userScrolled, setUserScrolled] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// remember which channel we've anchored, and how many messages we'd scrolled past
const scrollAnchor = useRef<{ channelId?: number; count: number }>({ count: 0 });
// reset scroll-tracking state when switching channels (adjust state during render, not in an effect)
const [prevChannelId, setPrevChannelId] = useState(channel?.id);
@@ -82,17 +83,25 @@ export function MessagePanel({ channel, heardCounts, iata, region, onAnalyze }:
setUserScrolled(false);
}
// ref reset belongs in an effect (refs must not be written during render)
useEffect(() => {
prevCount.current = 0;
}, [channel?.id]);
useLayoutEffect(() => {
const el = scrollContainerRef.current;
const anchor = scrollAnchor.current;
useEffect(() => {
if (sorted.length > prevCount.current && !userScrolled) {
if (anchor.channelId !== channel?.id) {
// first batch for this channel — wait for the fetch, then jump to the bottom (no animation)
if (isLoading) return;
anchor.channelId = channel?.id;
anchor.count = sorted.length;
if (el) el.scrollTop = el.scrollHeight;
return;
}
if (sorted.length > anchor.count && !userScrolled) {
// a live message landed mid-session — glide down to it
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}
prevCount.current = sorted.length;
}, [sorted.length, userScrolled]);
anchor.count = sorted.length;
}, [sorted.length, channel?.id, isLoading, userScrolled]);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
+25
View File
@@ -0,0 +1,25 @@
import { useEffect } from "react";
import { NodeDetailPanel } from "./NodeDetailPanel";
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 }: {
nodeId: string;
onClose: () => void;
onViewObserver: (observerId: string) => void;
}) {
useEffect(() => {
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose]);
return (
<ModalOverlay label="Node detail" onClose={onClose}>
<NodeDetailPanel nodeId={nodeId} onClose={onClose} onViewObserver={onViewObserver} />
</ModalOverlay>
);
}
+24 -5
View File
@@ -2,13 +2,16 @@ import { useQuery } from "@tanstack/react-query";
import { getNode, getNodeObservations } from "../../api/client";
import { Badge } from "../../components/Badge";
import { DetailPanel, Section, Field } from "../../components/DetailPanel";
import { formatHex, formatTimeOnly, formatSnr, snrLevel, timeAgoMs, microToDeg, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { formatHex, formatTimeOnly, formatSnr, snrLevel, timeAgoMs, microToDeg, formatRadio, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import type { NodeObservation } from "./types";
function NodeObservationRow({ obs }: { obs: NodeObservation }) {
function NodeObservationRow({ obs, onClick }: { obs: NodeObservation; onClick?: () => void }) {
const level = snrLevel(obs.snr);
return (
<div className="bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary">
<div
className={`bg-bg-base border border-border rounded px-3 py-2 border-l-2 border-l-primary ${onClick ? "cursor-pointer hover:bg-white/3" : ""}`}
onClick={onClick}
>
<div className="flex items-center gap-2 text-[11px] mb-1.5">
<Badge variant="default">{obs.payloadTypeName}</Badge>
<span className="font-mono text-primary font-semibold text-[11px] bg-primary/6 px-1.5 py-px rounded-sm">
@@ -43,9 +46,11 @@ function NodeObservationRow({ obs }: { obs: NodeObservation }) {
interface NodeDetailPanelProps {
nodeId: string;
onClose: () => void;
onViewObserver: (observerId: string) => void;
onAnalyzePacket?: (hash: string) => void;
}
export function NodeDetailPanel({ nodeId, onClose }: NodeDetailPanelProps) {
export function NodeDetailPanel({ nodeId, onClose, onViewObserver, onAnalyzePacket }: NodeDetailPanelProps) {
const { data: node, isLoading } = useQuery({
queryKey: ["node", nodeId],
queryFn: () => getNode(nodeId),
@@ -85,6 +90,15 @@ export function NodeDetailPanel({ nodeId, onClose }: NodeDetailPanelProps) {
<div className="font-mono text-[13px] text-text-muted truncate" title={node.publicKey}>
{node.publicKey}
</div>
{node.observerId && (
<button
type="button"
onClick={() => onViewObserver(node.observerId!)}
className="mt-2 block font-mono text-[11px] text-primary hover:underline"
>
View observer →
</button>
)}
</Section>
{(hasLocation || node.locationSource) && (
@@ -102,6 +116,7 @@ export function NodeDetailPanel({ nodeId, onClose }: NodeDetailPanelProps) {
{node.minFirmwareVersion && <Field label="Min firmware" value={node.minFirmwareVersion} />}
<Field label="Multibyte paths" value={node.supportsMultibytePaths ? "yes" : "no"} />
<Field label="Multibyte traces" value={node.supportsMultibyteTraces ? "yes" : "no"} />
{node.radio && <Field label="Radio" value={formatRadio(node.radio) ?? "—"} />}
</div>
</Section>
@@ -117,7 +132,11 @@ export function NodeDetailPanel({ nodeId, onClose }: NodeDetailPanelProps) {
{observations && observations.items.length > 0 ? (
<div className="flex flex-col gap-1.5">
{observations.items.map((obs) => (
<NodeObservationRow key={obs.id} obs={obs} />
<NodeObservationRow
key={obs.id}
obs={obs}
onClick={onAnalyzePacket ? () => onAnalyzePacket(obs.packetHash) : undefined}
/>
))}
</div>
) : (
+25 -12
View File
@@ -2,16 +2,17 @@ import { SearchBar, type SearchFieldOption } from "../../components/SearchBar";
import { SelectDropdown } from "../../components/SelectDropdown";
import { NODE_TYPE_OPTIONS } from "../../lib/node-types";
const CAPABILITY_OPTIONS = [
{ value: "paths", label: "Multibyte Paths" },
{ value: "traces", label: "Multibyte Traces" },
const MULTIBYTE_OPTIONS = [
{ value: "true", label: "Yes" },
{ value: "false", label: "No" },
];
const SEARCH_FIELDS: SearchFieldOption[] = [
{ value: "name", label: "Name" },
];
export type CapabilityFilter = "" | "paths" | "traces";
// "" means no filter (Any)
export type MultibyteFilter = "" | "true" | "false";
interface NodeFilterBarProps {
search: string;
@@ -20,8 +21,10 @@ interface NodeFilterBarProps {
onSearchFieldChange: (f: string) => void;
typeFilter: string;
onTypeChange: (t: string) => void;
capabilityFilter: CapabilityFilter;
onCapabilityChange: (c: CapabilityFilter) => void;
pathsFilter: MultibyteFilter;
onPathsChange: (v: MultibyteFilter) => void;
tracesFilter: MultibyteFilter;
onTracesChange: (v: MultibyteFilter) => void;
}
export function NodeFilterBar({
@@ -31,8 +34,10 @@ export function NodeFilterBar({
onSearchFieldChange,
typeFilter,
onTypeChange,
capabilityFilter,
onCapabilityChange,
pathsFilter,
onPathsChange,
tracesFilter,
onTracesChange,
}: NodeFilterBarProps) {
return (
<div
@@ -52,10 +57,18 @@ export function NodeFilterBar({
<SelectDropdown label="Type" options={NODE_TYPE_OPTIONS} value={typeFilter} onChange={onTypeChange} />
<SelectDropdown
label="Capabilities"
options={CAPABILITY_OPTIONS}
value={capabilityFilter}
onChange={(v) => onCapabilityChange(v as CapabilityFilter)}
label="Multibyte paths"
options={MULTIBYTE_OPTIONS}
allLabel="Any"
value={pathsFilter}
onChange={(v) => onPathsChange(v as MultibyteFilter)}
/>
<SelectDropdown
label="Multibyte traces"
options={MULTIBYTE_OPTIONS}
allLabel="Any"
value={tracesFilter}
onChange={(v) => onTracesChange(v as MultibyteFilter)}
/>
</div>
);
+35 -12
View File
@@ -4,10 +4,12 @@ import { getNodes } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { useTick } from "../../hooks/useTick";
import { useWsNodeUpdateHandler } from "../../hooks/useWsHandlers";
import { formatHex, microToDeg } from "../../lib/formatters";
import { formatHex, microToDeg, timeAgoMs, formatRadio } from "../../lib/formatters";
import { Badge } from "../../components/Badge";
import { Tooltip } from "../../components/Tooltip";
import { ObserverIcon } from "../../components/ObserverIcon";
import { DataTable, type Column } from "../../components/DataTable";
import { NodeFilterBar, type CapabilityFilter } from "./NodeFilterBar";
import { NodeFilterBar, type MultibyteFilter } from "./NodeFilterBar";
import { patchNodeSummary } from "./node-updates";
import type { NodeSummary } from "./types";
import type { WsManager } from "../../api/ws-manager";
@@ -23,6 +25,7 @@ interface NodeTableProps {
const COLUMNS: Column<NodeSummary>[] = [
{
header: "Name",
sortValue: (node) => node.name ?? formatHex(node.id),
cell: (node) => (
<span className={`truncate ${node.name ? "text-text-normal" : "text-text-dim italic"}`}>
{node.name ?? formatHex(node.id)}
@@ -31,15 +34,31 @@ const COLUMNS: Column<NodeSummary>[] = [
},
{
header: "Type",
cell: (node) => <Badge variant="default">{node.nodeTypeName}</Badge>,
sortValue: (node) => node.nodeTypeName,
cell: (node) => (
<Badge variant="default">
{node.isObserver && (
<Tooltip label="Observer" className="mr-1"><ObserverIcon /></Tooltip>
)}
{node.nodeTypeName}
</Badge>
),
},
{
header: "Radio",
className: "text-text-muted",
sortValue: (node) => formatRadio(node.radio) ?? null,
cell: (node) => formatRadio(node.radio) ?? "—",
},
{
header: "IATAs",
cell: (node) =>
node.iatas && node.iatas.length > 0 ? (
<div className="flex flex-wrap gap-1">
{node.iatas.map((code) => (
<Badge key={code} variant="default">{code}</Badge>
{node.iatas.map((entry) => (
<Tooltip key={entry.iata} label={`last heard ${timeAgoMs(entry.lastHeard)} ago`}>
<Badge variant="default">{entry.iata}</Badge>
</Tooltip>
))}
</div>
) : (
@@ -60,15 +79,16 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
const region = useRegion();
const queryClient = useQueryClient();
const [typeFilter, setTypeFilter] = useState("");
const [capabilityFilter, setCapabilityFilter] = useState<CapabilityFilter>("");
const [pathsFilter, setPathsFilter] = useState<MultibyteFilter>("");
const [tracesFilter, setTracesFilter] = useState<MultibyteFilter>("");
const [search, setSearch] = useState("");
const [searchField, setSearchField] = useState("name");
useTick();
const queryKey = useMemo(
() => ["nodes", region, typeFilter, capabilityFilter, search, searchField],
[region, typeFilter, capabilityFilter, search, searchField],
() => ["nodes", region, typeFilter, pathsFilter, tracesFilter, search, searchField],
[region, typeFilter, pathsFilter, tracesFilter, search, searchField],
);
const { data: nodes, isLoading } = useQuery({
@@ -78,8 +98,8 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
iata: region === "*" ? undefined : region,
type: typeFilter || undefined,
name: searchField === "name" ? search || undefined : undefined,
supportsMultibytePaths: capabilityFilter === "paths" || undefined,
supportsMultibyteTraces: capabilityFilter === "traces" || undefined,
supportsMultibytePaths: pathsFilter || undefined,
supportsMultibyteTraces: tracesFilter || undefined,
}),
staleTime: 30_000,
refetchInterval: 30_000,
@@ -108,8 +128,10 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
onSearchFieldChange={setSearchField}
typeFilter={typeFilter}
onTypeChange={setTypeFilter}
capabilityFilter={capabilityFilter}
onCapabilityChange={setCapabilityFilter}
pathsFilter={pathsFilter}
onPathsChange={setPathsFilter}
tracesFilter={tracesFilter}
onTracesChange={setTracesFilter}
/>
<DataTable
@@ -120,6 +142,7 @@ export function NodeTable({ wsManager, selectedNodeId, onSelectNode }: NodeTable
onSelect={onSelectNode}
isLoading={isLoading}
emptyLabel="No nodes"
defaultSort={{ header: "Name" }}
/>
</div>
</div>
+10 -3
View File
@@ -1,3 +1,8 @@
export interface NodeIATA {
iata: string;
lastHeard: number; // unix ms
}
export interface NodeSummary {
id: string;
publicKey: string;
@@ -6,10 +11,12 @@ export interface NodeSummary {
name: string | null;
lat: number | null;
lng: number | null;
iatas: string[];
// Role flag (a node that watches traffic for uplink); not populated by the API yet. When set, the
// map gives the node its observer-pip marker variant.
radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown
iatas: NodeIATA[];
// Set when this node also runs as an observer (watches traffic for uplink). isObserver drives the
// map's observer-pip marker variant; observerId, when present, links to that observer's detail.
isObserver?: boolean;
observerId?: string;
}
export interface Node extends NodeSummary {
+22 -10
View File
@@ -4,7 +4,7 @@ import { getObservers, getBrokers } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import { useTick } from "../../hooks/useTick";
import { useWsObserverStatusHandler } from "../../hooks/useWsHandlers";
import { formatHex } from "../../lib/formatters";
import { formatHex, formatRadio } from "../../lib/formatters";
import { Badge } from "../../components/Badge";
import { DataTable, type Column } from "../../components/DataTable";
import { ObserverFilterBar } from "./ObserverFilterBar";
@@ -15,11 +15,14 @@ import type { WsObserverStatus } from "../../types/ws";
interface ObserverTableProps {
wsManager: WsManager;
selectedObserverId: string | null;
onSelectObserver: (id: string | null) => void;
}
const COLUMNS: Column<ObserverSummary>[] = [
{
header: "Name",
sortValue: (obs) => obs.displayName ?? formatHex(obs.id),
cell: (obs) => (
<div className="flex items-center gap-2">
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${obs.status === "online" ? "bg-green" : "bg-text-dim/30"}`} />
@@ -32,23 +35,31 @@ const COLUMNS: Column<ObserverSummary>[] = [
{
header: "Type",
className: "text-text-muted",
sortValue: (obs) => obs.observerType ?? null,
cell: (obs) => obs.observerType ?? "—",
},
{
header: "Radio",
className: "text-text-muted",
sortValue: (obs) => formatRadio(obs.radio) ?? null,
cell: (obs) => formatRadio(obs.radio) ?? "—",
},
{
header: "IATA",
className: "text-text-normal",
sortValue: (obs) => obs.iata,
cell: (obs) => obs.iata,
},
{
header: "Status",
sortValue: (obs) => obs.status,
cell: (obs) => <Badge variant={obs.status === "online" ? "live" : "offline"}>{obs.status}</Badge>,
},
];
export function ObserverTable({ wsManager }: ObserverTableProps) {
export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver }: ObserverTableProps) {
const region = useRegion();
const queryClient = useQueryClient();
const [selectedId, setSelectedId] = useState<string | null>(null);
const [search, setSearch] = useState("");
const [searchField, setSearchField] = useState("name");
const [statusFilter, setStatusFilter] = useState("");
@@ -115,11 +126,11 @@ export function ObserverTable({ wsManager }: ObserverTableProps) {
return updated;
});
// refresh detail panel if it's showing this observer
if (selectedId === data.observerId) {
if (selectedObserverId === data.observerId) {
queryClient.invalidateQueries({ queryKey: ["observer", data.observerId] });
}
},
[queryClient, queryKey, selectedId],
[queryClient, queryKey, selectedObserverId],
);
useWsObserverStatusHandler(wsManager, handleObserverStatus);
@@ -146,17 +157,18 @@ export function ObserverTable({ wsManager }: ObserverTableProps) {
columns={COLUMNS}
rows={observers}
rowKey={(o) => o.id}
selectedKey={selectedId}
onSelect={setSelectedId}
selectedKey={selectedObserverId}
onSelect={onSelectObserver}
isLoading={isLoading}
emptyLabel="No observers"
defaultSort={{ header: "Name" }}
/>
</div>
{selectedId && (
{selectedObserverId && (
<ObserverDetailPanel
observerId={selectedId}
onClose={() => setSelectedId(null)}
observerId={selectedObserverId}
onClose={() => onSelectObserver(null)}
/>
)}
</div>
+1
View File
@@ -4,6 +4,7 @@ export interface ObserverSummary {
observerType?: string;
iata: string;
status: "online" | "offline";
radio?: string; // compact "freq,bw,sf" string, e.g. "915.0,250,11"; absent when unknown
}
export interface Observer extends ObserverSummary {
+16 -11
View File
@@ -1,10 +1,10 @@
import type { Observation } from "../../types/api";
import { formatTimeOnly, formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import { HopBadge } from "../../components/HopBadge";
import { PathData } from "./PathData";
// single observation with signal stats and resolved path
export function ObservationCard({ observation: obs, selected, onClick }: { observation: Observation; selected?: boolean; onClick?: () => void }) {
export function ObservationCard({ observation: obs, selected, onClick, onViewNode, isTrace }: { observation: Observation; selected?: boolean; onClick?: () => void; onViewNode?: (nodeId: string) => void; isTrace?: boolean }) {
const level = snrLevel(obs.snr);
return (
@@ -45,19 +45,24 @@ export function ObservationCard({ observation: obs, selected, onClick }: { obser
</div>
<div className="flex flex-col">
<span className="text-text-dim text-[10px] font-medium uppercase tracking-wider">Hops</span>
<span className="font-medium text-text-normal">{obs.hopCount}</span>
<span className="font-medium text-text-normal">{obs.pathLength.hopCount}</span>
</div>
</div>
{obs.resolvedPath.length > 0 && (
{obs.pathBytes && (
<div className="flex items-center gap-1 mt-2 font-mono text-[11px] pt-1.5 border-t border-border-subtle">
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">Path</span>
{obs.resolvedPath.map((hop, i) => (
<span key={i} className="contents">
{i > 0 && <span className="text-text-dim text-xs" aria-hidden>→</span>}
<HopBadge hop={hop} />
</span>
))}
{isTrace ? (
// TRACE path bytes are per-hop SNR samples, not hop hashes — show them raw, never as a resolvable path.
<>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">Path SNR</span>
<span className="text-text-normal break-all">{obs.pathBytes.toUpperCase()}</span>
</>
) : (
<>
<span className="text-text-dim uppercase text-[10px] font-medium tracking-wider mr-1">Path</span>
<PathData pathBytes={obs.pathBytes} hashSize={obs.pathLength.hashSize} resolvedPath={obs.resolvedPath} size="sm" onViewNode={onViewNode} />
</>
)}
</div>
)}
</div>
+57 -37
View File
@@ -2,11 +2,13 @@ import { useState, useCallback } from "react";
import type { PacketDetail } from "../../types/api";
import { PayloadType, PAYLOAD_TYPE_NAMES, ROUTE_TYPE_NAMES, type PayloadTypeValue, type RouteTypeValue } from "../../types/enums";
import { Badge } from "../../components/Badge";
import { Tooltip } from "../../components/Tooltip";
import { VARIANT_CLASSES, payloadTypeVariant } from "../../components/badge-utils";
import { formatHex, formatTimestamp } from "../../lib/formatters";
import { formatHex, formatTimestamp, formatPropagation } from "../../lib/formatters";
import { buildObservationFrame, computeFieldRanges, ColoredHexDump, HeaderBitBreakdown, PathLengthBitBreakdown, ColorAccentField, DrawerSection, ObservationDetail } from "./packet-structure";
import { PayloadBreakdown } from "./payload-renderers";
import { ObservationCard } from "./ObservationCard";
import { PathData } from "./PathData";
function decodePayloadHex(encoded: string): string | null {
try {
@@ -49,11 +51,15 @@ interface PacketAnalyzerDrawerProps {
open: boolean;
onToggle: () => void;
onSelectObservation?: (id: number) => void;
onViewNode?: (nodeId: string) => void;
loading?: boolean;
// false when embedded in a modal overlay: the header chevron becomes a Close (×) instead of collapse.
collapsible?: boolean;
}
// collapsible side panel showing packet structure and payload breakdown
export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onToggle, onSelectObservation }: PacketAnalyzerDrawerProps) {
export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onToggle, onSelectObservation, onViewNode, loading, collapsible = true }: PacketAnalyzerDrawerProps) {
const selectedObs = detail?.observations.find((o) => o.id === selectedObservationId)
?? detail?.observations[0]
?? null;
@@ -99,10 +105,16 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
type="button"
className="text-text-dim hover:text-text-normal cursor-pointer transition-colors"
onClick={onToggle}
aria-label="Collapse analyzer"
aria-label={collapsible ? "Collapse analyzer" : "Close analyzer"}
>
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
<path d="M6 4L10 8L6 12" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path
d={collapsible ? "M6 4L10 8L6 12" : "M4 4L12 12M12 4L4 12"}
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
@@ -116,7 +128,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
<line x1="3" y1="9" x2="21" y2="9" stroke="currentColor" strokeWidth="1.2" />
<line x1="8" y1="9" x2="8" y2="19" stroke="currentColor" strokeWidth="1.2" />
</svg>
<span className="text-[13px] font-mono">Select a packet to analyze</span>
<span className="text-[13px] font-mono">{loading ? "Loading…" : "Select a packet to analyze"}</span>
</div>
) : (
<>
@@ -125,21 +137,27 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
<span className="font-mono text-xs font-semibold text-primary tracking-wider">
{formatHex(detail.packetHash)}
</span>
<Badge variant={payloadTypeVariant(detail.payloadType)}>
{PAYLOAD_TYPE_NAMES[detail.payloadType as PayloadTypeValue] ?? "Unknown"}
<Badge variant={payloadTypeVariant(detail.header.payloadType)}>
{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "Unknown"}
</Badge>
<span
className="ml-auto font-mono text-[13px] text-primary font-semibold bg-primary/6 px-1.5 rounded-sm"
title={`Heard by ${detail.observations.length} observer${detail.observations.length === 1 ? "" : "s"}`}
aria-label={`Heard by ${detail.observations.length} observer${detail.observations.length === 1 ? "" : "s"}`}
<Tooltip
label={`Heard by ${detail.observations.length} observer${detail.observations.length === 1 ? "" : "s"}`}
className="ml-auto"
>
×{detail.observations.length}
</span>
<span
className="font-mono text-[13px] text-primary font-semibold bg-primary/6 px-1.5 rounded-sm"
aria-label={`Heard by ${detail.observations.length} observer${detail.observations.length === 1 ? "" : "s"}`}
>
×{detail.observations.length}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-3 text-[13px] font-mono">
<span><span className="text-text-dim">First </span><span className="text-text-normal">{formatTimestamp(detail.firstHeardAt)}</span></span>
<span className="text-[6px] text-border" aria-hidden>·</span>
<span><span className="text-text-dim">Last </span><span className="text-text-normal">{formatTimestamp(detail.lastHeardAt)}</span></span>
<span className="text-[6px] text-border" aria-hidden>·</span>
<span><span className="text-text-dim">Propagation </span><span className="text-text-normal">{formatPropagation(detail.firstToLastMs)}</span></span>
</div>
</DrawerSection>
@@ -158,6 +176,8 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
observation={obs}
selected={selectedObs?.id === obs.id}
onClick={onSelectObservation ? () => onSelectObservation(obs.id) : undefined}
onViewNode={onViewNode}
isTrace={detail.header.payloadType === PayloadType.TRACE}
/>
))}
</div>
@@ -178,9 +198,9 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
<ColorAccentField field="header">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Header Byte</div>
<div className="flex gap-x-4">
<span><span className="text-text-dim">Ver </span><span className="text-text-normal">{detail.payloadVersion}</span></span>
<span><span className="text-text-dim">Type </span><span className="text-text-normal">{PAYLOAD_TYPE_NAMES[detail.payloadType as PayloadTypeValue] ?? "?"} ({detail.payloadType})</span></span>
<span><span className="text-text-dim">Route </span><span className="text-text-normal">{ROUTE_TYPE_NAMES[detail.routeType as RouteTypeValue] ?? "?"} ({detail.routeType})</span></span>
<span><span className="text-text-dim">Ver </span><span className="text-text-normal">{detail.header.payloadVersion}</span></span>
<span><span className="text-text-dim">Type </span><span className="text-text-normal">{PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "?"} ({detail.header.payloadType})</span></span>
<span><span className="text-text-dim">Route </span><span className="text-text-normal">{ROUTE_TYPE_NAMES[detail.header.routeType as RouteTypeValue] ?? "?"} ({detail.header.routeType})</span></span>
</div>
{headerHex && (
<HeaderBitBreakdown headerHex={headerHex} />
@@ -191,7 +211,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
{detail.transportCodes && (
<ColorAccentField field="transport">
<span className="text-text-dim">Transport </span>
<span className="text-text-normal">{detail.transportCodes}</span>
<span className="text-text-normal">{detail.transportCodes.regionCode} / {detail.transportCodes.subRegionCode}</span>
</ColorAccentField>
)}
@@ -200,33 +220,33 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
<ColorAccentField field="pathLength">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Length</div>
<div className="flex gap-x-4">
<span><span className="text-text-dim">Hash Size </span><span className="text-text-normal">{selectedObs.hashSize}B</span></span>
<span><span className="text-text-dim">Hops </span><span className="text-text-normal">{selectedObs.hopCount}</span></span>
<span><span className="text-text-dim">Hash Size </span><span className="text-text-normal">{selectedObs.pathLength.hashSize}B</span></span>
<span><span className="text-text-dim">Hops </span><span className="text-text-normal">{selectedObs.pathLength.hopCount}</span></span>
</div>
<PathLengthBitBreakdown pathLengthByte={selectedObs.pathLengthByte} />
<PathLengthBitBreakdown pathLengthByte={parseInt(selectedObs.pathLength.raw, 16)} />
</ColorAccentField>
)}
{/* Path data */}
{/* Path data — for TRACE the path bytes are per-hop SNR samples, so show them raw */}
{selectedObs?.pathBytes && (
<ColorAccentField field="pathData">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Data</div>
<div className="flex flex-wrap items-center gap-1 text-[13px]">
{(() => {
const chars = selectedObs.hashSize * 2;
const hops = selectedObs.pathBytes.match(new RegExp(`.{1,${chars}}`, "g")) ?? [];
return hops.map((hop, i) => (
<span key={i} className="contents">
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
<span className="px-1.5 py-px rounded-sm bg-primary/6 text-primary font-semibold">{hop.toUpperCase()}</span>
</span>
));
})()}
</div>
{detail.header.payloadType === PayloadType.TRACE ? (
<>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path SNR Data</div>
<div className="font-mono text-[13px] text-text-normal break-all">
{selectedObs.pathBytes.toUpperCase()}
</div>
</>
) : (
<>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Data</div>
<PathData pathBytes={selectedObs.pathBytes} hashSize={selectedObs.pathLength.hashSize} resolvedPath={selectedObs.resolvedPath} onViewNode={onViewNode} />
</>
)}
</ColorAccentField>
)}
{detail.originPubkey && detail.payloadType !== PayloadType.ADVERT && (
{detail.originPubkey && detail.header.payloadType !== PayloadType.ADVERT && (
<ColorAccentField field="payload">
<span className="text-text-dim text-xs font-medium uppercase tracking-wider">Origin Pubkey</span>
<div className="text-text-normal break-all text-[13px]">{detail.originPubkey}</div>
@@ -249,9 +269,9 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
return (
<DrawerSection title="Payload Data">
<div className="bg-bg-base border border-border rounded p-2 max-h-40 overflow-y-auto">
<pre className="text-[13px] font-mono text-text-muted leading-relaxed overflow-x-auto whitespace-pre">
<pre className="text-[13px] font-mono text-text-muted leading-relaxed whitespace-pre-wrap">
{(hex.match(/.{1,2}/g) ?? []).reduce((acc, b, i) => {
const sep = i > 0 && i % 16 === 0 ? "\n" : i > 0 ? " " : "";
const sep = i > 0 ? " " : "";
return acc + sep + b.toUpperCase();
}, "")}
</pre>
@@ -0,0 +1,56 @@
import { useEffect, useState } from "react";
import type { PacketDetail } from "../../types/api";
import { PacketAnalyzerDrawer } from "./PacketAnalyzerDrawer";
import { NodeDetailOverlay } from "../nodes/NodeDetailOverlay";
import { ModalOverlay } from "../../components/ModalOverlay";
// Packet analyzer shown as a modal over the node detail panel: clicking a node's observation row
// pops the analyzer for that packet, dimming the rest, so a user can peek at the packet and close
// back to the node. The mirror image of NodeDetailOverlay (node panel over the packet analyzer).
//
// One level deeper: clicking a resolved path node inside the analyzer floats a node detail on top
// (node panel → packet analyzer → node detail). That bottom node panel gets no onAnalyzePacket, so
// the chain stops there rather than recursing forever.
export function PacketAnalyzerOverlay({ detail, loading, onClose, onViewObserver }: {
detail: PacketDetail | undefined;
loading?: boolean;
onClose: () => void;
onViewObserver: (observerId: string) => void;
}) {
const [selectedObservationId, setSelectedObservationId] = useState<number | null>(null);
const [viewNodeId, setViewNodeId] = useState<string | null>(null);
useEffect(() => {
function onKey(e: KeyboardEvent) {
// peel back one layer at a time: the nested node overlay handles its own Escape, so only
// close the analyzer once nothing is stacked above it
if (e.key === "Escape" && !viewNodeId) onClose();
}
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onClose, viewNodeId]);
return (
<>
<ModalOverlay label="Packet analyzer" onClose={onClose} inactive={!!viewNodeId}>
<PacketAnalyzerDrawer
detail={detail}
loading={loading}
collapsible={false}
selectedObservationId={selectedObservationId}
onSelectObservation={setSelectedObservationId}
open={true}
onToggle={onClose}
onViewNode={setViewNodeId}
/>
</ModalOverlay>
{viewNodeId && (
<NodeDetailOverlay
nodeId={viewNodeId}
onClose={() => setViewNodeId(null)}
onViewObserver={onViewObserver}
/>
)}
</>
);
}
+13 -8
View File
@@ -1,7 +1,9 @@
import { formatHex, formatTimestamp } from "../../lib/formatters";
import type { PacketSummary } from "../../types/api";
import { Badge } from "../../components/Badge";
import { Tooltip } from "../../components/Tooltip";
import { payloadTypeVariant } from "../../components/badge-utils";
import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums";
interface PacketRowProps {
packet: PacketSummary;
@@ -36,19 +38,22 @@ export function PacketRow({ packet, expanded, isFresh, onToggle }: PacketRowProp
<span className="font-mono text-xs font-semibold text-primary tracking-wider">
{formatHex(packet.packetHash)}
</span>
<Badge variant={payloadTypeVariant(packet.payloadType)}>{packet.payloadTypeName}</Badge>
<Badge variant={payloadTypeVariant(packet.payloadType)}>
{PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName}
</Badge>
{packet.summary && (
<span className="flex-1 text-text-bright text-xs whitespace-nowrap overflow-hidden text-ellipsis">
{packet.summary}
</span>
)}
<span
className="font-mono text-[11px] text-primary font-semibold whitespace-nowrap bg-primary/6 px-1.5 rounded-sm"
title={`Heard by ${packet.observationCount} observer${packet.observationCount === 1 ? "" : "s"}`}
aria-label={`Heard by ${packet.observationCount} observer${packet.observationCount === 1 ? "" : "s"}`}
>
×{packet.observationCount}
</span>
<Tooltip label={`Heard by ${packet.observationCount} observer${packet.observationCount === 1 ? "" : "s"}`}>
<span
className="font-mono text-[11px] text-primary font-semibold whitespace-nowrap bg-primary/6 px-1.5 rounded-sm"
aria-label={`Heard by ${packet.observationCount} observer${packet.observationCount === 1 ? "" : "s"}`}
>
×{packet.observationCount}
</span>
</Tooltip>
</div>
<div className="flex items-center gap-2 mt-1 text-[11px] text-text-dim">
+164
View File
@@ -0,0 +1,164 @@
import { useRef, useState, useEffect, useLayoutEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
import type { ResolvedHop, ResolvedNode } from "../../types/api";
import type { PathConfidence } from "../../types/enums";
// hash block tint by resolution confidence
const HOP_BLOCK_CLASSES: Record<PathConfidence, string> = {
high: "bg-green/8 text-green",
ambiguous: "bg-warn/8 text-warn",
none: "bg-text-muted/8 text-text-dim",
};
function nodeLabel(node: ResolvedNode): string {
return node.name ?? node.publicKey.slice(0, 8);
}
// Popover anchored above a path block. Opens on hover and stays open while the mouse is over the
// block OR the popover (a short close delay bridges the gap), so resolved nodes can be clicked.
// Portals to <body> so the drawer's overflow never clips it.
function HopPopover({ hop, onViewNode, children }: {
hop: ResolvedHop | undefined;
onViewNode?: (nodeId: string) => void;
children: ReactNode;
}) {
const ref = useRef<HTMLSpanElement>(null);
const tipRef = useRef<HTMLSpanElement>(null);
const [anchor, setAnchor] = useState<DOMRect | null>(null);
const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 });
const closeTimer = useRef<number | null>(null);
const nodes = hop?.nodes ?? [];
const clickable = nodes.length > 0 && !!onViewNode;
function open() {
if (closeTimer.current != null) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
const rect = ref.current?.getBoundingClientRect();
if (rect) setAnchor(rect);
}
function scheduleClose() {
closeTimer.current = window.setTimeout(() => setAnchor(null), 120);
}
// A fixed-position popover would drift away from its hash block on scroll/resize, so close it.
useEffect(() => {
if (!anchor) return;
const close = () => setAnchor(null);
window.addEventListener("scroll", close, true);
window.addEventListener("resize", close);
return () => {
window.removeEventListener("scroll", close, true);
window.removeEventListener("resize", close);
};
}, [anchor]);
// clear any pending close timer on unmount
useEffect(() => () => {
if (closeTimer.current != null) clearTimeout(closeTimer.current);
}, []);
// Center above the block, then clamp on-screen and flip below if it would clip the top edge.
useLayoutEffect(() => {
if (!anchor || !tipRef.current) return;
const { offsetWidth: w, offsetHeight: h } = tipRef.current;
const m = 6;
const left = Math.min(Math.max(anchor.left + anchor.width / 2 - w / 2, m), window.innerWidth - w - m);
const above = anchor.top - m - h;
setPos({ left, top: above >= m ? above : anchor.bottom + m });
}, [anchor]);
return (
<span ref={ref} onMouseEnter={open} onMouseLeave={scheduleClose} className="inline-flex">
{children}
{anchor &&
createPortal(
<span
ref={tipRef}
role="tooltip"
style={{ left: pos.left, top: pos.top }}
onMouseEnter={clickable ? open : undefined}
onMouseLeave={clickable ? scheduleClose : undefined}
className={`fixed z-50 flex flex-col gap-0.5 whitespace-nowrap rounded border border-border bg-bg-raised px-2 py-1 font-mono text-[11px] text-text-normal shadow-lg ${clickable ? "" : "pointer-events-none"}`}
>
{nodes.length === 0 ? (
"No Path Resolutions Available"
) : clickable ? (
nodes.map((node) => (
<button
key={node.id}
type="button"
onClick={(e) => {
e.stopPropagation();
setAnchor(null);
onViewNode?.(node.id);
}}
className="cursor-pointer text-left hover:text-primary hover:underline"
>
{nodeLabel(node)}
</button>
))
) : (
nodes.map((node) => <span key={node.id}>{nodeLabel(node)}</span>)
)}
</span>,
document.body,
)}
</span>
);
}
// Raw path-hash blocks, each tinted by its hop's resolution confidence and showing the resolved
// node(s) on hover. resolvedPath[i] lines up with the i-th hash (the backend appends one hop per
// hash in order), so a missing/short entry falls back to "none". When onViewNode is provided, a
// single-match block opens that node directly and ambiguous candidates are clickable in the popover.
export function PathData({ pathBytes, hashSize, resolvedPath, size = "md", onViewNode }: {
pathBytes: string;
hashSize: number;
resolvedPath: ResolvedHop[];
size?: "sm" | "md";
onViewNode?: (nodeId: string) => void;
}) {
const chars = hashSize * 2;
// A 0-byte hash size would make the splitter `.{1,0}`, which the RegExp constructor rejects.
// There's nothing to lay out without a hash size anyway, so bail.
if (chars <= 0) return null;
const hops = pathBytes.match(new RegExp(`.{1,${chars}}`, "g")) ?? [];
const textClass = size === "sm" ? "text-[11px]" : "text-[13px]";
return (
<div className={`flex flex-wrap items-center gap-1 font-mono ${textClass}`}>
{hops.map((hop, i) => {
const resolved = resolvedPath[i];
const confidence: PathConfidence = resolved?.confidence ?? "none";
const nodes = resolved?.nodes ?? [];
const blockClass = `px-1.5 py-px rounded-sm font-semibold ${HOP_BLOCK_CLASSES[confidence]}`;
// single resolved node → click the block directly; ambiguous is disambiguated via the popover
const single = nodes.length === 1 && onViewNode ? nodes[0] : undefined;
return (
<span key={i} className="contents">
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
<HopPopover hop={resolved} onViewNode={onViewNode}>
{single ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onViewNode?.(single.id);
}}
className={`${blockClass} cursor-pointer hover:brightness-125`}
>
{hop.toUpperCase()}
</button>
) : (
<span className={blockClass}>{hop.toUpperCase()}</span>
)}
</HopPopover>
</span>
);
})}
</div>
);
}
+81 -36
View File
@@ -1,13 +1,12 @@
/* eslint-disable react-refresh/only-export-components */
import { Fragment, useState } from "react";
import type { PacketDetail, Observation } from "../../types/api";
import { RouteType } from "../../types/enums";
import { HopBadge } from "../../components/HopBadge";
import { RouteType, PayloadType } from "../../types/enums";
import { formatTimeOnly, formatSnr, snrLevel, formatPropagation, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
// maps packet bytes to named field ranges for hex coloring
export type FieldId = "header" | "transport" | "pathLength" | "pathData" | "payload" | "channelHash" | "cipherMac" | "ciphertext" | "publicKey" | "signature" | "advertTimestamp" | "flags" | "location" | "advertName" | "destinationHash" | "sourceHash" | "senderPublicKey" | "checksum";
export type FieldId = "header" | "transport" | "pathLength" | "pathData" | "payload" | "channelHash" | "cipherMac" | "ciphertext" | "publicKey" | "signature" | "advertTimestamp" | "flags" | "location" | "advertName" | "destinationHash" | "sourceHash" | "senderPublicKey" | "checksum" | "traceTag" | "authCode" | "tracePath";
export interface ByteRange {
start: number;
@@ -15,13 +14,17 @@ export interface ByteRange {
}
// Reconstruct the full on-air frame for one observer:
// rawHeader + pathLengthByte + pathBytes + rawPayload
// header.raw + pathLength.raw + pathBytes + rawPayload
// Header/payload are packet-scope (observer-independent); the path is per-observer.
export function buildObservationFrame(detail: PacketDetail, obs: Observation | null): string {
const header = detail.rawHeader ?? "";
// Header and path-length are each one on-air byte that computeFieldRanges always reserves, so force
// every value to a full 2-char byte: pad a stray nibble, and coerce a missing/empty value to "00"
// rather than dropping a byte and shifting every field after it out of alignment.
const pad = (b?: string) => (!b ? "00" : b.length === 1 ? "0" + b : b);
const header = pad(detail.header.raw);
const payload = detail.rawPayload ?? "";
if (!obs) return header + payload;
const pathLen = obs.pathLengthByte.toString(16).padStart(2, "0");
const pathLen = pad(obs.pathLength.raw);
const path = obs.pathBytes ?? "";
return header + pathLen + path + payload;
}
@@ -37,8 +40,8 @@ export function computeFieldRanges(
let offset = 1;
const hasTransport =
detail.routeType === RouteType.TRANSPORT_FLOOD ||
detail.routeType === RouteType.TRANSPORT_DIRECT;
detail.header.routeType === RouteType.TRANSPORT_FLOOD ||
detail.header.routeType === RouteType.TRANSPORT_DIRECT;
if (hasTransport) {
ranges.transport = { start: offset, end: offset + 4 };
offset += 4;
@@ -48,7 +51,7 @@ export function computeFieldRanges(
ranges.pathLength = { start: offset, end: offset + 1 };
offset += 1;
const pathByteCount = obs.hashSize * obs.hopCount;
const pathByteCount = obs.pathLength.hashSize * obs.pathLength.hopCount;
if (pathByteCount > 0) {
ranges.pathData = { start: offset, end: offset + pathByteCount };
}
@@ -60,7 +63,9 @@ export function computeFieldRanges(
const pp = detail.parsedPayload;
if (pp && typeof pp === "object") {
if (pp.type === "ADVERT" && typeof pp.publicKey === "string") {
// ADVERT: gate on the header's numeric payload type — it's the authoritative source and
// doesn't depend on how parsedPayload happens to be shaped.
if (detail.header.payloadType === PayloadType.ADVERT && typeof pp.publicKey === "string") {
let sub = offset;
const pkLen = (pp.publicKey as string).length / 2;
ranges.publicKey = { start: sub, end: sub + pkLen };
@@ -69,26 +74,75 @@ export function computeFieldRanges(
ranges.advertTimestamp = { start: sub, end: sub + 4 };
sub += 4;
if (typeof pp.signature === "string") {
const sigLen = (pp.signature as string).length / 2;
ranges.signature = { start: sub, end: sub + sigLen };
sub += sigLen;
}
// The signature is a fixed 64-byte Ed25519 value; use the parsed length when it's present
// and fall back to 64 as a safety net.
const sigLen = typeof pp.signature === "string" ? (pp.signature as string).length / 2 : 64;
ranges.signature = { start: sub, end: sub + sigLen };
sub += sigLen;
// Field ranges are raw-byte offsets, so read the flags byte straight from the frame rather
// than the parsed booleans (which live nested under appData.flags). Masks match
// AdvertFlagsBitBreakdown. A short/missing payload yields NaN, and `NaN & mask` is 0, so each
// flag falls to false on its own.
const flagsIdx = pkLen + 4 + sigLen;
const flags = parseInt((detail.rawPayload ?? "").slice(flagsIdx * 2, flagsIdx * 2 + 2), 16);
const hasLocation = (flags & 0x10) !== 0;
const hasFeature1 = (flags & 0x20) !== 0;
const hasFeature2 = (flags & 0x40) !== 0;
const hasName = (flags & 0x80) !== 0;
if (sub < totalBytes) {
ranges.flags = { start: sub, end: sub + 1 };
sub += 1;
}
if (pp.hasLocation && sub + 8 <= totalBytes) {
if (hasLocation && sub + 8 <= totalBytes) {
ranges.location = { start: sub, end: sub + 8 };
sub += 8;
}
if (pp.hasFeature1) sub += 2;
if (pp.hasFeature2) sub += 2;
if (pp.hasName && sub < totalBytes) {
if (hasFeature1) sub += 2;
if (hasFeature2) sub += 2;
if (hasName && sub < totalBytes) {
ranges.advertName = { start: sub, end: totalBytes };
}
} else if (detail.header.payloadType === PayloadType.ANON_REQ && typeof pp.ephemeralPubKey === "string") {
// Lean ANON_REQ shape: 1-byte destination hash, ephemeral pubkey, then the
// encrypted block (2-byte MAC + ciphertext). The 1B/2B sizes are MeshCore
// conventions, not derivable from the parsed payload.
let sub = offset;
if (sub < totalBytes) {
ranges.destinationHash = { start: sub, end: sub + 1 };
sub += 1;
}
const keyLen = (pp.ephemeralPubKey as string).length / 2;
ranges.senderPublicKey = { start: sub, end: sub + keyLen };
sub += keyLen;
if (sub + 2 <= totalBytes) {
ranges.cipherMac = { start: sub, end: sub + 2 };
sub += 2;
}
if (sub < totalBytes) {
ranges.ciphertext = { start: sub, end: totalBytes };
}
} else if (detail.header.payloadType === PayloadType.TRACE && typeof pp.traceTag === "string") {
// TRACE: traceTag(4) + authCode(4, uint32) + flags(1) + path hashes (1 byte each)
let sub = offset;
const tagLen = (pp.traceTag as string).length / 2;
ranges.traceTag = { start: sub, end: sub + tagLen };
sub += tagLen;
ranges.authCode = { start: sub, end: sub + 4 };
sub += 4;
if (sub < totalBytes) {
ranges.flags = { start: sub, end: sub + 1 };
sub += 1;
}
if (sub < totalBytes) {
ranges.tracePath = { start: sub, end: totalBytes };
}
} else {
let hexFields: FieldId[];
if (pp.type === "ACK" && pp.checksum) {
@@ -127,7 +181,7 @@ export const FIELD_COLORS: Record<FieldId, { hex: string; accent: string }> = {
header: { hex: "text-secondary", accent: "border-l-secondary/50" },
transport: { hex: "text-warn", accent: "border-l-warn/50" },
pathLength: { hex: "text-primary", accent: "border-l-primary/50" },
pathData: { hex: "text-green", accent: "border-l-green/50" },
pathData: { hex: "text-secondary", accent: "border-l-secondary/50" },
payload: { hex: "text-text-muted", accent: "border-l-text-muted/50" },
channelHash: { hex: "text-primary", accent: "border-l-primary/50" },
cipherMac: { hex: "text-warn", accent: "border-l-warn/50" },
@@ -142,6 +196,9 @@ export const FIELD_COLORS: Record<FieldId, { hex: string; accent: string }> = {
sourceHash: { hex: "text-secondary", accent: "border-l-secondary/50" },
senderPublicKey: { hex: "text-green", accent: "border-l-green/50" },
checksum: { hex: "text-warn", accent: "border-l-warn/50" },
traceTag: { hex: "text-primary", accent: "border-l-primary/50" },
authCode: { hex: "text-green", accent: "border-l-green/50" },
tracePath: { hex: "text-secondary", accent: "border-l-secondary/50" },
};
export function DrawerSection({ title, children, first, collapsible, defaultOpen = true }: { title: string; children: React.ReactNode; first?: boolean; collapsible?: boolean; defaultOpen?: boolean }) {
@@ -192,16 +249,14 @@ export function ColoredHexDump({
}
return (
<pre className="text-[13px] font-mono leading-relaxed overflow-x-auto whitespace-pre">
<pre className="text-[13px] font-mono leading-relaxed whitespace-pre-wrap">
{bytes.map((byte, i) => {
const field = byteFieldMap.get(i) ?? null;
const colorClass = field ? FIELD_COLORS[field].hex : "text-text-muted";
const isLineStart = i % 16 === 0 && i > 0;
return (
<Fragment key={i}>
{isLineStart && "\n"}
{i % 16 !== 0 && " "}
{i > 0 && " "}
<span className={colorClass}>{byte.toUpperCase()}</span>
</Fragment>
);
@@ -244,6 +299,7 @@ export function HeaderBitBreakdown({ headerHex }: { headerHex: string }) {
}
export function PathLengthBitBreakdown({ pathLengthByte }: { pathLengthByte: number }) {
if (Number.isNaN(pathLengthByte)) return null;
const bits = formatBinary(pathLengthByte);
const hashBits = bits.slice(0, 2);
const hopBits = bits.slice(2, 8);
@@ -357,7 +413,7 @@ export function ObservationDetail({ observation }: { observation: Observation })
<span><span className="text-text-dim">SNR </span><span className={sigClass}>{formatSnr(observation.snr)}</span></span>
<span><span className="text-text-dim">RSSI </span><span className={sigClass}>{observation.rssi ?? "—"}</span></span>
<span><span className="text-text-dim">Prop </span><span className="text-text-normal">{formatPropagation(observation.propagationTimeMs)}</span></span>
<span><span className="text-text-dim">Hops </span><span className="text-text-normal">{observation.hopCount}</span></span>
<span><span className="text-text-dim">Hops </span><span className="text-text-normal">{observation.pathLength.hopCount}</span></span>
</div>
{observation.radio && (
@@ -370,17 +426,6 @@ export function ObservationDetail({ observation }: { observation: Observation })
</div>
)}
{observation.resolvedPath.length > 0 && (
<div className="flex flex-wrap items-center gap-1 text-[13px] pt-1 border-t border-border-subtle">
<span className="text-text-dim text-xs font-medium uppercase tracking-wider mr-1">Path</span>
{observation.resolvedPath.map((hop, i) => (
<span key={i} className="contents">
{i > 0 && <span className="text-text-dim text-xs" aria-hidden>→</span>}
<HopBadge hop={hop} />
</span>
))}
</div>
)}
</div>
);
}
+74 -78
View File
@@ -128,15 +128,16 @@ function formatUnixTs(unix: number): string {
function AdvertPayload({ payload }: PayloadProps) {
const p = payload;
const name = p.name as string | null | undefined;
const publicKey = p.publicKey as string | undefined;
const signature = p.signature as string | undefined;
const signatureValid = p.signatureValid as boolean | undefined;
const timestamp = p.timestamp as number | undefined;
const latitude = p.latitude as number | null | undefined;
const longitude = p.longitude as number | null | undefined;
const hasLocation = p.hasLocation as boolean | undefined;
const flagsByte = p.flags as number | undefined;
const appData = (p.appData ?? {}) as Record<string, unknown>;
const flags = (appData.flags ?? {}) as Record<string, unknown>;
const flagsByte = typeof flags.raw === "string" ? parseInt(flags.raw, 16) : undefined;
const latitude = appData.latitude as number | null | undefined;
const longitude = appData.longitude as number | null | undefined;
const hasLocation = flags.hasLocation as boolean | undefined;
const name = appData.name as string | null | undefined;
return (
<div className="flex flex-col gap-2.5">
@@ -161,34 +162,29 @@ function AdvertPayload({ payload }: PayloadProps) {
<span className="text-text-dim">Signature </span>
<span className="text-text-normal">{signature.slice(0, 16).toUpperCase()}…</span>
<span className="text-text-dim"> ({signature.length / 2}B)</span>
{signatureValid != null && (
<>
{" "}
<FlagChip label={signatureValid ? "Valid" : "Invalid"} active={signatureValid} variant={signatureValid ? "green" : "danger"} />
</>
)}
</ColorAccentField>
)}
<ColorAccentField field="flags">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Flags Byte</div>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">App Flags</div>
<div className="flex gap-x-4">
<span><span className="text-text-dim">Role </span><span className="text-text-normal">{String(p.deviceRoleName ?? "?")}</span></span>
<span><span className="text-text-dim">Loc </span><BooleanValue value={!!p.hasLocation} /></span>
<span><span className="text-text-dim">F1 </span><BooleanValue value={!!p.hasFeature1} /></span>
<span><span className="text-text-dim">F2 </span><BooleanValue value={!!p.hasFeature2} /></span>
<span><span className="text-text-dim">Name </span><BooleanValue value={!!p.hasName} /></span>
<span><span className="text-text-dim">Role </span><span className="text-text-normal">{String(flags.deviceRoleName ?? "?")}</span></span>
<span><span className="text-text-dim">Loc </span><BooleanValue value={!!flags.hasLocation} /></span>
<span><span className="text-text-dim">F1 </span><BooleanValue value={!!flags.hasFeature1} /></span>
<span><span className="text-text-dim">F2 </span><BooleanValue value={!!flags.hasFeature2} /></span>
<span><span className="text-text-dim">Name </span><BooleanValue value={!!flags.hasName} /></span>
</div>
{flagsByte != null && <AdvertFlagsBitBreakdown flagsByte={flagsByte} />}
{flagsByte != null && !Number.isNaN(flagsByte) && <AdvertFlagsBitBreakdown flagsByte={flagsByte} />}
</ColorAccentField>
{/* Coordinates render as raw decimal degrees by design — intentionally not D°M' / N·S·E·W formatted. */}
{hasLocation && (latitude != null || longitude != null) && (
<ColorAccentField field="location">
<span className="text-text-dim">Location </span>
<span className="text-text-normal">
{latitude != null ? `${Math.abs(latitude).toFixed(4)}° ${latitude >= 0 ? "N" : "S"}` : "—"}
{latitude != null ? latitude.toFixed(5) : "—"}
{", "}
{longitude != null ? `${Math.abs(longitude).toFixed(4)}° ${longitude >= 0 ? "E" : "W"}` : "—"}
{longitude != null ? longitude.toFixed(5) : "—"}
</span>
<span className="text-text-dim"> (8B LE)</span>
</ColorAccentField>
@@ -208,47 +204,55 @@ function AdvertPayload({ payload }: PayloadProps) {
function TracePayload({ payload }: PayloadProps) {
const traceTag = payload.traceTag as string | undefined;
const authCode = payload.authCode as number | undefined;
const flags = payload.flags as number | undefined;
const pathHashes = payload.pathHashes as string[] | undefined;
const snrValues = payload.snrValues as number[] | null | undefined;
const snrValues = payload.snrValues as number[] | undefined;
return (
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-3">
{traceTag && (
<div>
<span className="text-text-dim">Tag </span>
<HexBadge value={traceTag} />
</div>
)}
{authCode != null && (
<FieldRow label="Auth">
<span className="text-text-normal">0x{authCode.toString(16).toUpperCase()}</span>
</FieldRow>
)}
</div>
{traceTag && (
<ColorAccentField field="traceTag">
<span className="text-text-dim">Trace Tag </span>
<span className="text-text-normal">{traceTag.toUpperCase()}</span>
<span className="text-text-dim"> ({traceTag.length / 2}B)</span>
</ColorAccentField>
)}
{authCode != null && (
<ColorAccentField field="authCode">
<span className="text-text-dim">Auth Code </span>
<span className="text-text-normal">0x{authCode.toString(16).toUpperCase()}</span>
<span className="text-text-dim"> (4B)</span>
</ColorAccentField>
)}
{flags != null && (
<ColorAccentField field="flags">
<span className="text-text-dim">Flags </span>
<span className="text-text-normal">0x{flags.toString(16).toUpperCase().padStart(2, "0")}</span>
</ColorAccentField>
)}
{pathHashes && pathHashes.length > 0 && (
<div>
<SectionLabel>Trace Path</SectionLabel>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2">
<ColorAccentField field="tracePath">
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Trace Path</div>
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2 text-[13px]">
{pathHashes.map((hash, i) => {
const snr = snrValues?.[i] ?? null;
const level = snrLevel(snr);
const snr = snrValues?.[i];
const level = snr != null ? snrLevel(snr) : null;
const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal";
return (
<span key={i} className="contents">
{i > 0 && <span className="text-text-dim text-xs" aria-hidden>→</span>}
{i > 0 && <span className="text-text-dim" aria-hidden>→</span>}
<span className="inline-flex flex-col items-center gap-0.5">
<HexBadge value={hash} />
{snr != null && (
<span className={`text-[11px] ${sigClass}`}>{formatSnr(snr)} dB</span>
)}
{snr != null && <span className={`text-[11px] ${sigClass}`}>{formatSnr(snr)} dB</span>}
</span>
</span>
);
})}
</div>
</div>
</ColorAccentField>
)}
</div>
);
@@ -355,34 +359,6 @@ function ResponsePayload({ payload }: PayloadProps) {
);
}
function AnonRequestPayload({ payload }: PayloadProps) {
const senderPublicKey = payload.senderPublicKey as string | undefined;
return (
<EncryptedEnvelope
payload={payload}
headerSlot={senderPublicKey && <TruncatedHex label="Sender Key" value={senderPublicKey} accentClass={FIELD_COLORS.senderPublicKey.accent} />}
>
{(d) => (
<div className="flex flex-col gap-1">
{d.timestamp != null && (
<FieldRow label="Time">
<span className="text-text-normal">{formatUnixTs(d.timestamp as number)}</span>
</FieldRow>
)}
{d.syncTimestamp != null && (
<FieldRow label="Sync Since">
<span className="text-text-normal">{formatUnixTs(d.syncTimestamp as number)}</span>
</FieldRow>
)}
{d.password != null && (
<FieldRow label="Password"><span className="text-text-normal">{String(d.password)}</span></FieldRow>
)}
</div>
)}
</EncryptedEnvelope>
);
}
function AckPayload({ payload }: PayloadProps) {
const checksum = payload.checksum as string | undefined;
if (!checksum) return null;
@@ -542,7 +518,8 @@ function ControlPayload({ payload }: PayloadProps) {
}
function RawPayload({ payload }: PayloadProps) {
const raw = payload.raw as string | undefined;
// RAW packets carry the bytes under `data`; older shapes used `raw`.
const raw = (payload.raw ?? payload.data) as string | undefined;
if (!raw) return null;
return <TruncatedHex label="Raw" value={raw} maxChars={32} />;
}
@@ -653,6 +630,24 @@ function GenericPayload({ payload }: PayloadProps) {
);
}
function AnonReqPayload({ payload }: PayloadProps) {
const destination = payload.destination as number | undefined;
const ephemeralPubKey = payload.ephemeralPubKey as string | undefined;
return (
<div className="flex flex-col gap-2.5">
{destination != null && (
<FieldRow label="Dest Hash">
<span className="text-text-normal">0x{destination.toString(16).toUpperCase().padStart(2, "0")}</span>
</FieldRow>
)}
{ephemeralPubKey && (
<TruncatedHex label="Ephemeral Key" value={ephemeralPubKey} accentClass={FIELD_COLORS.senderPublicKey.accent} />
)}
</div>
);
}
// routes payload.type to the right renderer
export function PayloadBreakdown({ payload }: { payload: Record<string, unknown> }) {
@@ -663,13 +658,14 @@ export function PayloadBreakdown({ payload }: { payload: Record<string, unknown>
case "TEXT_MESSAGE": return <TextPayload payload={payload} />;
case "REQUEST": return <RequestPayload payload={payload} />;
case "RESPONSE": return <ResponsePayload payload={payload} />;
case "ANON_REQUEST": return <AnonRequestPayload payload={payload} />;
case "ANON_REQUEST": return <AnonReqPayload payload={payload} />;
case "ACK": return <AckPayload payload={payload} />;
case "PATH": return <PathPayload payload={payload} />;
case "CONTROL": return <ControlPayload payload={payload} />;
case "GROUP_DATA":
case "MULTI_PART":
case "CUSTOM": return <RawPayload payload={payload} />;
case "GROUP_DATA": return <GroupTextPayload payload={payload} />;
case "RAW": return <RawPayload payload={payload} />;
// MULTIPART carries structured remaining/wrappedType/wrappedPayload fields, which the
// generic key/value renderer shows verbatim.
default: return <GenericPayload payload={payload} />;
}
}
+8 -4
View File
@@ -4,8 +4,7 @@ import { getPackets } from "../../api/client";
import { useRegion } from "../../hooks/useRegion";
import type { WsPacketObservation, WsLagged } from "../../types/ws";
import type { PacketSummary } from "../../types/api";
import { ROUTE_TYPE_NAMES, type RouteTypeValue } from "../../types/enums";
import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES } from "../../lib/constants";
import { LIVE_BUFFER_CAP, MAX_INFINITE_PAGES, INITIAL_PACKET_PAGE_SIZE } from "../../lib/constants";
// merge and deduplicate live + paginated packets
@@ -140,7 +139,7 @@ export function usePackets() {
payloadType: data.packet.payloadType,
payloadTypeName: data.packet.payloadTypeName,
routeType: data.packet.routeType,
routeTypeName: ROUTE_TYPE_NAMES[data.packet.routeType as RouteTypeValue] ?? "Unknown",
routeTypeName: data.packet.routeTypeName,
firstHeardAt: data.observation.heardAt,
lastHeardAt: data.observation.heardAt,
observationCount: data.packet.observationCount,
@@ -173,7 +172,12 @@ export function usePackets() {
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ["packets", region],
queryFn: ({ pageParam }) => getPackets(region, { cursor: pageParam }),
queryFn: ({ pageParam }) =>
getPackets(region, {
cursor: pageParam,
// first load grabs a bigger batch so the list isn't sparse; scroll pages stay default-sized
limit: pageParam === undefined ? INITIAL_PACKET_PAGE_SIZE : undefined,
}),
getNextPageParam: (last) => last.nextCursor ?? undefined,
initialPageParam: undefined as number | undefined,
staleTime: Infinity,
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, type RefObject } from "react";
const FOCUSABLE = [
"a[href]",
"button:not([disabled])",
"textarea:not([disabled])",
"input:not([disabled])",
"select:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
// Keeps keyboard focus inside a modal container: focuses the container itself on open (so a screen
// reader announces the dialog before Tab moves into the controls), cycles Tab/Shift+Tab within it so
// focus can't escape behind the overlay, and on close returns focus to the triggering element (only
// if it's still a real, connected control). The container must carry tabIndex={-1} to be focusable.
export function useFocusTrap<T extends HTMLElement>(ref: RefObject<T | null>): void {
useEffect(() => {
const node = ref.current;
if (!node) return;
const restoreTo = document.activeElement as HTMLElement | null;
const focusable = () => Array.from(node.querySelectorAll<HTMLElement>(FOCUSABLE));
// Focus the dialog itself (it carries tabIndex={-1}) so a screen reader announces its name before
// the first Tab moves into the controls.
node.focus();
function onKeyDown(e: KeyboardEvent) {
if (e.key !== "Tab") return;
const els = focusable();
const first = els[0];
const last = els[els.length - 1];
if (!first || !last) {
e.preventDefault(); // nothing focusable inside — don't let Tab escape the modal
return;
}
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
node.addEventListener("keydown", onKeyDown);
return () => {
node.removeEventListener("keydown", onKeyDown);
// Only return focus to a real, still-connected trigger. If the modal was opened from a click that
// left focus on <body>, restoring would blur wherever the user has since moved.
if (restoreTo && restoreTo !== document.body && document.contains(restoreTo)) restoreTo.focus();
};
}, [ref]);
}
+1
View File
@@ -4,6 +4,7 @@ export const WS_URL = import.meta.env.VITE_WS_URL ?? "ws://localhost:8080/ws";
export const LIVE_BUFFER_CAP = 500;
export const MAX_INFINITE_PAGES = 20;
export const DEFAULT_PAGE_SIZE = 50;
export const INITIAL_PACKET_PAGE_SIZE = 100;
export const SCROLL_TOP_THRESHOLD_PX = 100;
export const SCROLL_BOTTOM_THRESHOLD_PX = 500;
+12
View File
@@ -82,3 +82,15 @@ export function timeAgoMs(epochMs: number): string {
export function timeAgo(iso: string): string {
return timeAgoMs(new Date(iso).getTime());
}
// Node/observer summaries carry radio as a compact "freq,bw,sf" string (e.g. "915.0,250,11").
// Formats freq/SF/bandwidth like the observer panel ("915 MHz · SF11 · 250 kHz"); the compact
// string carries no coding rate, so there's no "CR 4/x" segment.
export function formatRadio(radio: string | null | undefined): string | null {
if (!radio) return null;
const [freq, bw, sf] = radio.split(",");
if (!freq || !bw || !sf) return radio; // unexpected shape — show it raw rather than hide it
const f = Number(freq), b = Number(bw);
if (Number.isNaN(f) || Number.isNaN(b)) return radio; // non-numeric freq/bw — show raw, not "NaN MHz"
return `${f} MHz · SF${sf} · ${b} kHz`;
}
+32 -16
View File
@@ -29,15 +29,23 @@ export interface PacketSummary {
summary?: string;
}
export interface ResolvedNode {
id: string; // uuid
name?: string;
publicKey: string; // hex-encoded prefix
latitude?: number; // decimal degrees (resolved DB value, not the 1e7 advert encoding)
longitude?: number;
}
export interface ResolvedHop {
confidence: PathConfidence;
node?: {
id: string;
name?: string;
publicKey: string;
latitude?: number;
longitude?: number;
};
nodes: ResolvedNode[]; // empty when confidence is "none"
}
export interface PathLength {
raw: string; // path-length byte as hex (e.g. "1e")
hashSize: number; // bytes per hop hash
hopCount: number;
}
export interface Observation {
@@ -46,9 +54,7 @@ export interface Observation {
observerName?: string;
iata: string;
heardAt: number;
pathLengthByte: number;
hashSize: number;
hopCount: number;
pathLength: PathLength;
pathBytes?: string;
rssi?: number;
snr?: number;
@@ -63,22 +69,32 @@ export interface Observation {
resolvedPath: ResolvedHop[];
}
export interface PacketDetail {
packetHash: string;
export interface PacketHeader {
raw: string; // header byte as hex (e.g. "11")
routeType: number;
routeTypeName: string;
payloadType: number;
payloadTypeName: string;
payloadVersion: number;
routeType: number;
routeTypeName: string;
transportCodes?: string;
}
export interface TransportCodes {
regionCode: number;
subRegionCode: number;
}
export interface PacketDetail {
packetHash: string;
header: PacketHeader;
transportCodes?: TransportCodes;
originPubkey?: string;
parsedPayload?: Record<string, unknown> | string;
rawHeader: string;
rawPayload: string;
decrypted: boolean;
channelHash?: string;
firstHeardAt: number;
lastHeardAt: number;
firstToLastMs: number; // ms between first and last hearing — the packet's overall propagation time
observationCount: number;
observations: Observation[];
}
+5 -3
View File
@@ -12,6 +12,7 @@ export const PayloadType = {
PATH: 0x08,
TRACE: 0x09,
MULTI_PART: 0x0a,
CONTROL: 0x0b,
CUSTOM: 0x0f,
} as const;
@@ -19,8 +20,8 @@ export type PayloadTypeValue = (typeof PayloadType)[keyof typeof PayloadType];
export const PAYLOAD_TYPE_NAMES: Record<PayloadTypeValue, string> = {
[PayloadType.REQUEST]: "REQ",
[PayloadType.RESPONSE]: "TXT_MSG",
[PayloadType.TEXT]: "SENSOR_DATA",
[PayloadType.RESPONSE]: "RESPONSE",
[PayloadType.TEXT]: "TXT_MSG",
[PayloadType.ACK]: "ACK",
[PayloadType.ADVERT]: "ADVERT",
[PayloadType.GROUP_TEXT]: "GRP_TXT",
@@ -29,6 +30,7 @@ export const PAYLOAD_TYPE_NAMES: Record<PayloadTypeValue, string> = {
[PayloadType.PATH]: "PATH",
[PayloadType.TRACE]: "TRACE",
[PayloadType.MULTI_PART]: "MULTI_PART",
[PayloadType.CONTROL]: "CONTROL",
[PayloadType.CUSTOM]: "CUSTOM",
};
@@ -50,4 +52,4 @@ export const ROUTE_TYPE_NAMES: Record<RouteTypeValue, string> = {
[RouteType.TRANSPORT_DIRECT]: "TRANSPORT_DIRECT",
};
export type PathConfidence = "high" | "low" | "unknown";
export type PathConfidence = "high" | "ambiguous" | "none";
+1
View File
@@ -38,6 +38,7 @@ export interface WsPacketObservation {
payloadType: number;
payloadTypeName: string;
routeType: number;
routeTypeName: string;
isFirstObservation: boolean;
observationCount: number;
};
@@ -0,0 +1,149 @@
import { describe, it, expect } from "vitest";
import { buildObservationFrame, computeFieldRanges } from "../../../src/features/packets/packet-structure";
import { PayloadType, RouteType } from "../../../src/types/enums";
import type { PacketDetail, Observation } from "../../../src/types/api";
// Minimal PacketDetail with a 1-byte header and a non-transport route, so the
// payload starts at byte offset 1 (no transport codes, and obs=null skips the path).
// parsedPayload here is a deliberately minimal stub: computeFieldRanges keys off the numeric
// header type and reads only top-level publicKey/signature plus the raw bytes, so these fixtures
// don't mirror the backend's full shape (which carries `type` and nests advert data under appData).
function makeDetail(opts: {
payloadType: number;
parsedPayload?: Record<string, unknown> | string;
rawPayload: string;
}): PacketDetail {
return {
packetHash: "deadbeef",
header: {
raw: "01",
routeType: RouteType.FLOOD,
routeTypeName: "FLOOD",
payloadType: opts.payloadType,
payloadTypeName: "x",
payloadVersion: 0,
},
parsedPayload: opts.parsedPayload,
rawPayload: opts.rawPayload,
decrypted: false,
firstHeardAt: 0,
lastHeardAt: 0,
firstToLastMs: 0,
observationCount: 0,
observations: [],
};
}
const rep = (byte: string, n: number) => byte.repeat(n);
function makeObs(opts: { raw: string; hashSize: number; hopCount: number; pathBytes?: string }): Observation {
return {
id: 1,
observerId: "obs-1",
iata: "YYZ",
heardAt: 0,
pathLength: { raw: opts.raw, hashSize: opts.hashSize, hopCount: opts.hopCount },
pathBytes: opts.pathBytes,
sourceBroker: "b",
resolvedPath: [],
};
}
function rangesFor(detail: PacketDetail) {
const frame = buildObservationFrame(detail, null);
return computeFieldRanges(detail, null, frame.length / 2);
}
describe("computeFieldRanges — ADVERT (lean backend shape, no `type` field)", () => {
it("colors public key, timestamp, signature, flags, location, and name", () => {
const publicKey = rep("aa", 32); // 32-byte key
const rawPayload =
publicKey + // 32B pubkey
"01020304" + // 4B timestamp
rep("bb", 64) + // 64B signature (omitted from the parsed payload)
"90" + // flags 0x90 = location(0x10) + name(0x80)
rep("cc", 8) + // 8B location
"4869"; // "Hi" (2B)
const ranges = rangesFor(
makeDetail({
payloadType: PayloadType.ADVERT,
parsedPayload: { publicKey, name: "Hi", nodeType: "ChatNode", timestamp: 1, lat: 1, lon: 2 },
rawPayload,
}),
);
expect(ranges.publicKey).toEqual({ start: 1, end: 33 });
expect(ranges.advertTimestamp).toEqual({ start: 33, end: 37 });
expect(ranges.signature).toEqual({ start: 37, end: 101 });
expect(ranges.flags).toEqual({ start: 101, end: 102 });
expect(ranges.location).toEqual({ start: 102, end: 110 });
expect(ranges.advertName).toEqual({ start: 110, end: 112 });
});
it("omits location and name when their flag bits are clear", () => {
const publicKey = rep("aa", 32);
const rawPayload = publicKey + "01020304" + rep("bb", 64) + "00"; // flags 0x00
const ranges = rangesFor(
makeDetail({
payloadType: PayloadType.ADVERT,
parsedPayload: { publicKey, timestamp: 1 },
rawPayload,
}),
);
expect(ranges.publicKey).toEqual({ start: 1, end: 33 });
expect(ranges.signature).toEqual({ start: 37, end: 101 });
expect(ranges.flags).toEqual({ start: 101, end: 102 });
expect(ranges.location).toBeUndefined();
expect(ranges.advertName).toBeUndefined();
});
});
describe("buildObservationFrame — path-length byte alignment", () => {
it("reserves a full path-length byte even when pathLength.raw is empty, so the payload stays aligned", () => {
// computeFieldRanges always reserves one byte for the path length when there's an observation,
// so the reconstructed frame must too — otherwise every field after the header shifts a byte.
const obs = makeObs({ raw: "", hashSize: 0, hopCount: 0 });
const detail = makeDetail({ payloadType: PayloadType.ANON_REQ, rawPayload: "aabbccdd" });
const frame = buildObservationFrame(detail, obs);
expect(frame).toBe("01" + "00" + "aabbccdd"); // header + path-length(00) + payload, nothing dropped
const ranges = computeFieldRanges(detail, obs, frame.length / 2);
expect(ranges.pathLength).toEqual({ start: 1, end: 2 });
expect(ranges.payload).toEqual({ start: 2, end: 6 }); // payload begins right after the path-length byte
});
it("pads a single-nibble path-length byte to two hex chars", () => {
const obs = makeObs({ raw: "e", hashSize: 0, hopCount: 0 });
const detail = makeDetail({ payloadType: PayloadType.ANON_REQ, rawPayload: "aabbccdd" });
expect(buildObservationFrame(detail, obs)).toBe("01" + "0e" + "aabbccdd");
});
});
describe("computeFieldRanges — ANON_REQ (lean backend shape, no `type` field)", () => {
it("colors destination, ephemeral key, MAC, and ciphertext", () => {
const ephemeralPubKey = rep("dd", 32);
const rawPayload =
"2a" + // 1B destination (42)
ephemeralPubKey + // 32B ephemeral pubkey
"eeee" + // 2B cipher MAC
rep("ff", 10); // 10B ciphertext
const ranges = rangesFor(
makeDetail({
payloadType: PayloadType.ANON_REQ,
parsedPayload: { destination: 42, ephemeralPubKey },
rawPayload,
}),
);
expect(ranges.destinationHash).toEqual({ start: 1, end: 2 });
expect(ranges.senderPublicKey).toEqual({ start: 2, end: 34 });
expect(ranges.cipherMac).toEqual({ start: 34, end: 36 });
expect(ranges.ciphertext).toEqual({ start: 36, end: 46 });
});
});
+74
View File
@@ -0,0 +1,74 @@
import { useRef, type ReactNode } from "react";
import { describe, it, expect } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { useFocusTrap } from "../../src/hooks/useFocusTrap";
function TrapBox({ children }: { children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
useFocusTrap(ref);
return (
<div ref={ref} tabIndex={-1} data-testid="dialog">
{children}
</div>
);
}
function Harness({ trapped }: { trapped: boolean }) {
return (
<div>
<button data-testid="outside">outside</button>
{trapped && (
<TrapBox>
<button data-testid="first">first</button>
<button data-testid="last">last</button>
</TrapBox>
)}
</div>
);
}
describe("useFocusTrap", () => {
it("focuses the container itself on mount (so the dialog name is announced before its controls)", () => {
render(<Harness trapped={true} />);
expect(document.activeElement).toBe(screen.getByTestId("dialog"));
});
it("wraps focus to the first element when Tab is pressed on the last", () => {
render(<Harness trapped={true} />);
screen.getByTestId("last").focus();
fireEvent.keyDown(screen.getByTestId("last"), { key: "Tab" });
expect(document.activeElement).toBe(screen.getByTestId("first"));
});
it("wraps focus to the last element when Shift+Tab is pressed on the first", () => {
render(<Harness trapped={true} />);
screen.getByTestId("first").focus();
fireEvent.keyDown(screen.getByTestId("first"), { key: "Tab", shiftKey: true });
expect(document.activeElement).toBe(screen.getByTestId("last"));
});
it("does not yank focus to <body> on unmount when nothing was focused on open", () => {
// Opened from a click that left focus on <body> (e.g. a Safari/Firefox button or a non-focusable
// row). On close we must not call body.focus() and blur whatever the user moved to next.
const { rerender } = render(<Harness trapped={false} />);
expect(document.activeElement).toBe(document.body);
rerender(<Harness trapped={true} />); // trap focuses the container; restoreTo is <body>
screen.getByTestId("outside").focus(); // focus subsequently moves elsewhere
rerender(<Harness trapped={false} />); // unmount must leave focus on "outside", not reset to body
expect(document.activeElement).toBe(screen.getByTestId("outside"));
});
it("restores focus to the previously focused element on unmount", () => {
const { rerender } = render(<Harness trapped={false} />);
screen.getByTestId("outside").focus();
expect(document.activeElement).toBe(screen.getByTestId("outside"));
rerender(<Harness trapped={true} />);
expect(document.activeElement).toBe(screen.getByTestId("dialog"));
rerender(<Harness trapped={false} />);
expect(document.activeElement).toBe(screen.getByTestId("outside"));
});
});