mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 12:12:34 +00:00
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:
1 parent
d726e2659d
commit
4b8109262b
31 files changed
+1177
-285
No files matched your search
+58
-5
@@ -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
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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";
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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"));
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user