diff --git a/src/App.tsx b/src/App.tsx index 2dc323c..6be7660 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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(() => searchParams.get("hash")); const [selectedObservationId, setSelectedObservationId] = useState(null); const [selectedNodeId, setSelectedNodeId] = useState(null); + // lifted (like selectedNodeId) so a node's "View observer" link can select it before the tab mounts + const [selectedObserverId, setSelectedObserverId] = useState(null); + // node detail shown as a modal over the packet analyzer (e.g. clicking a resolved path hop) + const [overlayNodeId, setOverlayNodeId] = useState(null); + // packet analyzer shown as a modal over the node panel (clicking a node's observation row) + const [overlayPacketHash, setOverlayPacketHash] = useState(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 = { Packets: , Nodes: , - Observers: , + Observers: , Channels: , Stats: , Map: , @@ -169,7 +191,7 @@ function AppInner() { -
+
}> {tabContent[activeTab]} @@ -178,14 +200,45 @@ function AppInner() { {analyzerHash && (activeTab === "Packets" || activeTab === "Channels") && ( )} {(activeTab === "Map" || activeTab === "Nodes") && selectedNodeId && ( - setSelectedNodeId(null)} /> + setSelectedNodeId(null)} + onViewObserver={(observerId) => { + setSelectedObserverId(observerId); + handleTabChange("Observers"); + }} + onAnalyzePacket={setOverlayPacketHash} + /> + )} + {overlayNodeId && ( + setOverlayNodeId(null)} + onViewObserver={(observerId) => { + setSelectedObserverId(observerId); + handleTabChange("Observers"); + }} + /> + )} + {overlayPacketHash && ( + setOverlayPacketHash(null)} + onViewObserver={(observerId) => { + setSelectedObserverId(observerId); + handleTabChange("Observers"); + }} + /> )}
diff --git a/src/api/client.ts b/src/api/client.ts index e0e560a..b6a32f0 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -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 { @@ -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; diff --git a/src/components/DataTable.tsx b/src/components/DataTable.tsx index 8560345..d53d414 100644 --- a/src/components/DataTable.tsx +++ b/src/components/DataTable.tsx @@ -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 { header: string; cell: (row: T) => ReactNode; className?: string; // extra classes applied to the + sortValue?: (row: T) => string | number | null | undefined; // column is sortable when present } +type SortDirection = "asc" | "desc"; + interface DataTableProps { columns: Column[]; rows: T[] | undefined; @@ -16,11 +19,42 @@ interface DataTableProps { 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({ columns, rows, rowKey, selectedKey, onSelect, isLoading, emptyLabel }: DataTableProps) { +export function DataTable({ columns, rows, rowKey, selectedKey, onSelect, isLoading, emptyLabel, defaultSort }: DataTableProps) { + 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 (
@@ -31,17 +65,34 @@ export function DataTable({ columns, rows, rowKey, selectedKey, onSelect, isL return (
- {rows && rows.length > 0 ? ( + {sortedRows && sortedRows.length > 0 ? ( - {columns.map((col) => ( - - ))} + {columns.map((col) => { + if (!col.sortValue) { + return ; + } + const active = sort.header === col.header; + return ( + + ); + })} - {rows.map((row) => { + {sortedRows.map((row) => { const key = rowKey(row); const isSelected = key === selectedKey; return ( diff --git a/src/components/HopBadge.tsx b/src/components/HopBadge.tsx deleted file mode 100644 index 92f72b6..0000000 --- a/src/components/HopBadge.tsx +++ /dev/null @@ -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 ( - - {hop.node.name ?? hop.node.publicKey.slice(0, 8)} - - ); - } - if (hop.confidence === "low" && hop.node) { - return ( - - {hop.node.name ?? hop.node.publicKey.slice(0, 8)} - - ); - } - return ( - - ?? - - ); -} diff --git a/src/components/ModalOverlay.tsx b/src/components/ModalOverlay.tsx new file mode 100644 index 0000000..11465a3 --- /dev/null +++ b/src/components/ModalOverlay.tsx @@ -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(null); + useFocusTrap(ref); + + return ( +
+
e.stopPropagation()}> + {children} +
+
+ ); +} diff --git a/src/components/ObserverIcon.tsx b/src/components/ObserverIcon.tsx new file mode 100644 index 0000000..a4c68f6 --- /dev/null +++ b/src/components/ObserverIcon.tsx @@ -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 ( + + ); +} diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx new file mode 100644 index 0000000..b867e4a --- /dev/null +++ b/src/components/Tooltip.tsx @@ -0,0 +1,56 @@ +import { useState, useRef, useEffect, useLayoutEffect, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +// Lightweight hover tooltip. Renders into 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(null); + const tipRef = useRef(null); + const [anchor, setAnchor] = useState(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 ( + + {children} + {anchor && + createPortal( + + {label} + , + document.body, + )} + + ); +} diff --git a/src/features/channels/MessagePanel.tsx b/src/features/channels/MessagePanel.tsx index dba7822..7fee99d 100644 --- a/src/features/channels/MessagePanel.tsx +++ b/src/features/channels/MessagePanel.tsx @@ -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(null); - const prevCount = useRef(0); const [userScrolled, setUserScrolled] = useState(false); const scrollContainerRef = useRef(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; diff --git a/src/features/nodes/NodeDetailOverlay.tsx b/src/features/nodes/NodeDetailOverlay.tsx new file mode 100644 index 0000000..01e2b19 --- /dev/null +++ b/src/features/nodes/NodeDetailOverlay.tsx @@ -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 ( + + + + ); +} diff --git a/src/features/nodes/NodeDetailPanel.tsx b/src/features/nodes/NodeDetailPanel.tsx index 35083d6..3942402 100644 --- a/src/features/nodes/NodeDetailPanel.tsx +++ b/src/features/nodes/NodeDetailPanel.tsx @@ -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 ( -
+
{obs.payloadTypeName} @@ -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) {
{node.publicKey}
+ {node.observerId && ( + + )} {(hasLocation || node.locationSource) && ( @@ -102,6 +116,7 @@ export function NodeDetailPanel({ nodeId, onClose }: NodeDetailPanelProps) { {node.minFirmwareVersion && } + {node.radio && }
@@ -117,7 +132,11 @@ export function NodeDetailPanel({ nodeId, onClose }: NodeDetailPanelProps) { {observations && observations.items.length > 0 ? (
{observations.items.map((obs) => ( - + onAnalyzePacket(obs.packetHash) : undefined} + /> ))}
) : ( diff --git a/src/features/nodes/NodeFilterBar.tsx b/src/features/nodes/NodeFilterBar.tsx index 6e0ebcc..ca8e711 100644 --- a/src/features/nodes/NodeFilterBar.tsx +++ b/src/features/nodes/NodeFilterBar.tsx @@ -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 (
onCapabilityChange(v as CapabilityFilter)} + label="Multibyte paths" + options={MULTIBYTE_OPTIONS} + allLabel="Any" + value={pathsFilter} + onChange={(v) => onPathsChange(v as MultibyteFilter)} + /> + onTracesChange(v as MultibyteFilter)} />
); diff --git a/src/features/nodes/NodeTable.tsx b/src/features/nodes/NodeTable.tsx index 2bbe15c..8833676 100644 --- a/src/features/nodes/NodeTable.tsx +++ b/src/features/nodes/NodeTable.tsx @@ -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[] = [ { header: "Name", + sortValue: (node) => node.name ?? formatHex(node.id), cell: (node) => ( {node.name ?? formatHex(node.id)} @@ -31,15 +34,31 @@ const COLUMNS: Column[] = [ }, { header: "Type", - cell: (node) => {node.nodeTypeName}, + sortValue: (node) => node.nodeTypeName, + cell: (node) => ( + + {node.isObserver && ( + + )} + {node.nodeTypeName} + + ), + }, + { + 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 ? (
- {node.iatas.map((code) => ( - {code} + {node.iatas.map((entry) => ( + + {entry.iata} + ))}
) : ( @@ -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(""); + const [pathsFilter, setPathsFilter] = useState(""); + const [tracesFilter, setTracesFilter] = useState(""); 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} />
diff --git a/src/features/nodes/types.ts b/src/features/nodes/types.ts index a4b9d61..293defe 100644 --- a/src/features/nodes/types.ts +++ b/src/features/nodes/types.ts @@ -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 { diff --git a/src/features/observers/ObserverTable.tsx b/src/features/observers/ObserverTable.tsx index 34e3feb..c607ff0 100644 --- a/src/features/observers/ObserverTable.tsx +++ b/src/features/observers/ObserverTable.tsx @@ -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[] = [ { header: "Name", + sortValue: (obs) => obs.displayName ?? formatHex(obs.id), cell: (obs) => (
@@ -32,23 +35,31 @@ const COLUMNS: Column[] = [ { 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) => {obs.status}, }, ]; -export function ObserverTable({ wsManager }: ObserverTableProps) { +export function ObserverTable({ wsManager, selectedObserverId, onSelectObserver }: ObserverTableProps) { const region = useRegion(); const queryClient = useQueryClient(); - const [selectedId, setSelectedId] = useState(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" }} />
- {selectedId && ( + {selectedObserverId && ( setSelectedId(null)} + observerId={selectedObserverId} + onClose={() => onSelectObserver(null)} /> )} diff --git a/src/features/observers/types.ts b/src/features/observers/types.ts index 3289f43..a9737ab 100644 --- a/src/features/observers/types.ts +++ b/src/features/observers/types.ts @@ -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 { diff --git a/src/features/packets/ObservationCard.tsx b/src/features/packets/ObservationCard.tsx index 782d759..75d0a80 100644 --- a/src/features/packets/ObservationCard.tsx +++ b/src/features/packets/ObservationCard.tsx @@ -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
Hops - {obs.hopCount} + {obs.pathLength.hopCount}
- {obs.resolvedPath.length > 0 && ( + {obs.pathBytes && (
- Path - {obs.resolvedPath.map((hop, i) => ( - - {i > 0 && →} - - - ))} + {isTrace ? ( + // TRACE path bytes are per-hop SNR samples, not hop hashes — show them raw, never as a resolvable path. + <> + Path SNR + {obs.pathBytes.toUpperCase()} + + ) : ( + <> + Path + + + )}
)} diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 38e9121..572a7bc 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -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"} > - + @@ -116,7 +128,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo - Select a packet to analyze + {loading ? "Loading…" : "Select a packet to analyze"} ) : ( <> @@ -125,21 +137,27 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo {formatHex(detail.packetHash)} - - {PAYLOAD_TYPE_NAMES[detail.payloadType as PayloadTypeValue] ?? "Unknown"} + + {PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "Unknown"} - - ×{detail.observations.length} - + + ×{detail.observations.length} + +
First {formatTimestamp(detail.firstHeardAt)} · Last {formatTimestamp(detail.lastHeardAt)} + · + Propagation {formatPropagation(detail.firstToLastMs)}
@@ -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} /> ))} @@ -178,9 +198,9 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
Header Byte
- Ver {detail.payloadVersion} - Type {PAYLOAD_TYPE_NAMES[detail.payloadType as PayloadTypeValue] ?? "?"} ({detail.payloadType}) - Route {ROUTE_TYPE_NAMES[detail.routeType as RouteTypeValue] ?? "?"} ({detail.routeType}) + Ver {detail.header.payloadVersion} + Type {PAYLOAD_TYPE_NAMES[detail.header.payloadType as PayloadTypeValue] ?? "?"} ({detail.header.payloadType}) + Route {ROUTE_TYPE_NAMES[detail.header.routeType as RouteTypeValue] ?? "?"} ({detail.header.routeType})
{headerHex && ( @@ -191,7 +211,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo {detail.transportCodes && ( Transport - {detail.transportCodes} + {detail.transportCodes.regionCode} / {detail.transportCodes.subRegionCode} )} @@ -200,33 +220,33 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo
Path Length
- Hash Size {selectedObs.hashSize}B - Hops {selectedObs.hopCount} + Hash Size {selectedObs.pathLength.hashSize}B + Hops {selectedObs.pathLength.hopCount}
- +
)} - {/* Path data */} + {/* Path data — for TRACE the path bytes are per-hop SNR samples, so show them raw */} {selectedObs?.pathBytes && ( -
Path Data
-
- {(() => { - const chars = selectedObs.hashSize * 2; - const hops = selectedObs.pathBytes.match(new RegExp(`.{1,${chars}}`, "g")) ?? []; - return hops.map((hop, i) => ( - - {i > 0 && →} - {hop.toUpperCase()} - - )); - })()} -
+ {detail.header.payloadType === PayloadType.TRACE ? ( + <> +
Path SNR Data
+
+ {selectedObs.pathBytes.toUpperCase()} +
+ + ) : ( + <> +
Path Data
+ + + )}
)} - {detail.originPubkey && detail.payloadType !== PayloadType.ADVERT && ( + {detail.originPubkey && detail.header.payloadType !== PayloadType.ADVERT && ( Origin Pubkey
{detail.originPubkey}
@@ -249,9 +269,9 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, open, onTo return (
-
+                    
                       {(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();
                       }, "")}
                     
diff --git a/src/features/packets/PacketAnalyzerOverlay.tsx b/src/features/packets/PacketAnalyzerOverlay.tsx new file mode 100644 index 0000000..509c1d9 --- /dev/null +++ b/src/features/packets/PacketAnalyzerOverlay.tsx @@ -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(null); + const [viewNodeId, setViewNodeId] = useState(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 ( + <> + + + + {viewNodeId && ( + setViewNodeId(null)} + onViewObserver={onViewObserver} + /> + )} + + ); +} diff --git a/src/features/packets/PacketRow.tsx b/src/features/packets/PacketRow.tsx index ae94c30..1eb0d8e 100644 --- a/src/features/packets/PacketRow.tsx +++ b/src/features/packets/PacketRow.tsx @@ -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 {formatHex(packet.packetHash)} - {packet.payloadTypeName} + + {PAYLOAD_TYPE_NAMES[packet.payloadType as PayloadTypeValue] ?? packet.payloadTypeName} + {packet.summary && ( {packet.summary} )} - - ×{packet.observationCount} - + + + ×{packet.observationCount} + +
diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx new file mode 100644 index 0000000..c9859cf --- /dev/null +++ b/src/features/packets/PathData.tsx @@ -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 = { + 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 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(null); + const tipRef = useRef(null); + const [anchor, setAnchor] = useState(null); + const [pos, setPos] = useState<{ left: number; top: number }>({ left: 0, top: 0 }); + const closeTimer = useRef(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 ( + + {children} + {anchor && + createPortal( + + {nodes.length === 0 ? ( + "No Path Resolutions Available" + ) : clickable ? ( + nodes.map((node) => ( + + )) + ) : ( + nodes.map((node) => {nodeLabel(node)}) + )} + , + document.body, + )} + + ); +} + +// 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 ( +
+ {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 ( + + {i > 0 && →} + + {single ? ( + + ) : ( + {hop.toUpperCase()} + )} + + + ); + })} +
+ ); +} diff --git a/src/features/packets/packet-structure.tsx b/src/features/packets/packet-structure.tsx index c9e808f..e0b040c 100644 --- a/src/features/packets/packet-structure.tsx +++ b/src/features/packets/packet-structure.tsx @@ -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 = { 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 = { 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 ( -
+    
       {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 (
           
-            {isLineStart && "\n"}
-            {i % 16 !== 0 && " "}
+            {i > 0 && " "}
             {byte.toUpperCase()}
           
         );
@@ -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 })
         SNR {formatSnr(observation.snr)}
         RSSI {observation.rssi ?? "—"}
         Prop {formatPropagation(observation.propagationTimeMs)}
-        Hops {observation.hopCount}
+        Hops {observation.pathLength.hopCount}
       
{observation.radio && ( @@ -370,17 +426,6 @@ export function ObservationDetail({ observation }: { observation: Observation }) )} - {observation.resolvedPath.length > 0 && ( -
- Path - {observation.resolvedPath.map((hop, i) => ( - - {i > 0 && →} - - - ))} -
- )} ); } diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index bda7034..d8c4917 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -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; + const flags = (appData.flags ?? {}) as Record; + 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 (
@@ -161,34 +162,29 @@ function AdvertPayload({ payload }: PayloadProps) { Signature {signature.slice(0, 16).toUpperCase()}… ({signature.length / 2}B) - {signatureValid != null && ( - <> - {" "} - - - )} )} -
Flags Byte
+
App Flags
- Role {String(p.deviceRoleName ?? "?")} - Loc - F1 - F2 - Name + Role {String(flags.deviceRoleName ?? "?")} + Loc + F1 + F2 + Name
- {flagsByte != null && } + {flagsByte != null && !Number.isNaN(flagsByte) && }
+ {/* Coordinates render as raw decimal degrees by design — intentionally not D°M' / N·S·E·W formatted. */} {hasLocation && (latitude != null || longitude != null) && ( Location - {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) : "—"} (8B LE) @@ -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 (
-
- {traceTag && ( -
- Tag - -
- )} - {authCode != null && ( - - 0x{authCode.toString(16).toUpperCase()} - - )} -
+ {traceTag && ( + + Trace Tag + {traceTag.toUpperCase()} + ({traceTag.length / 2}B) + + )} + + {authCode != null && ( + + Auth Code + 0x{authCode.toString(16).toUpperCase()} + (4B) + + )} + + {flags != null && ( + + Flags + 0x{flags.toString(16).toUpperCase().padStart(2, "0")} + + )} {pathHashes && pathHashes.length > 0 && ( -
- Trace Path -
+ +
Trace Path
+
{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 ( - {i > 0 && →} + {i > 0 && →} - {snr != null && ( - {formatSnr(snr)} dB - )} + {snr != null && {formatSnr(snr)} dB} ); })}
-
+ )}
); @@ -355,34 +359,6 @@ function ResponsePayload({ payload }: PayloadProps) { ); } -function AnonRequestPayload({ payload }: PayloadProps) { - const senderPublicKey = payload.senderPublicKey as string | undefined; - return ( - } - > - {(d) => ( -
- {d.timestamp != null && ( - - {formatUnixTs(d.timestamp as number)} - - )} - {d.syncTimestamp != null && ( - - {formatUnixTs(d.syncTimestamp as number)} - - )} - {d.password != null && ( - {String(d.password)} - )} -
- )} -
- ); -} - 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 ; } @@ -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 ( +
+ {destination != null && ( + + 0x{destination.toString(16).toUpperCase().padStart(2, "0")} + + )} + {ephemeralPubKey && ( + + )} +
+ ); +} + // routes payload.type to the right renderer export function PayloadBreakdown({ payload }: { payload: Record }) { @@ -663,13 +658,14 @@ export function PayloadBreakdown({ payload }: { payload: Record case "TEXT_MESSAGE": return ; case "REQUEST": return ; case "RESPONSE": return ; - case "ANON_REQUEST": return ; + case "ANON_REQUEST": return ; case "ACK": return ; case "PATH": return ; case "CONTROL": return ; - case "GROUP_DATA": - case "MULTI_PART": - case "CUSTOM": return ; + case "GROUP_DATA": return ; + case "RAW": return ; + // MULTIPART carries structured remaining/wrappedType/wrappedPayload fields, which the + // generic key/value renderer shows verbatim. default: return ; } } diff --git a/src/features/packets/usePackets.ts b/src/features/packets/usePackets.ts index fae702b..5de3f05 100644 --- a/src/features/packets/usePackets.ts +++ b/src/features/packets/usePackets.ts @@ -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, diff --git a/src/hooks/useFocusTrap.ts b/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000..400d30a --- /dev/null +++ b/src/hooks/useFocusTrap.ts @@ -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(ref: RefObject): void { + useEffect(() => { + const node = ref.current; + if (!node) return; + const restoreTo = document.activeElement as HTMLElement | null; + + const focusable = () => Array.from(node.querySelectorAll(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 , restoring would blur wherever the user has since moved. + if (restoreTo && restoreTo !== document.body && document.contains(restoreTo)) restoreTo.focus(); + }; + }, [ref]); +} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index f262a76..9b49b48 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -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; diff --git a/src/lib/formatters.ts b/src/lib/formatters.ts index 15a7fe7..327ea99 100644 --- a/src/lib/formatters.ts +++ b/src/lib/formatters.ts @@ -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`; +} diff --git a/src/types/api.ts b/src/types/api.ts index e135912..8e80e06 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -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; - 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[]; } diff --git a/src/types/enums.ts b/src/types/enums.ts index c7460d1..900ed99 100644 --- a/src/types/enums.ts +++ b/src/types/enums.ts @@ -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 = { [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 = { [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 = { [RouteType.TRANSPORT_DIRECT]: "TRANSPORT_DIRECT", }; -export type PathConfidence = "high" | "low" | "unknown"; +export type PathConfidence = "high" | "ambiguous" | "none"; diff --git a/src/types/ws.ts b/src/types/ws.ts index a75ec1b..9349243 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -38,6 +38,7 @@ export interface WsPacketObservation { payloadType: number; payloadTypeName: string; routeType: number; + routeTypeName: string; isFirstObservation: boolean; observationCount: number; }; diff --git a/tests/features/packets/packet-structure.test.ts b/tests/features/packets/packet-structure.test.ts new file mode 100644 index 0000000..230fe12 --- /dev/null +++ b/tests/features/packets/packet-structure.test.ts @@ -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; + 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 }); + }); +}); diff --git a/tests/hooks/useFocusTrap.test.tsx b/tests/hooks/useFocusTrap.test.tsx new file mode 100644 index 0000000..9e41bc5 --- /dev/null +++ b/tests/hooks/useFocusTrap.test.tsx @@ -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(null); + useFocusTrap(ref); + return ( +
+ {children} +
+ ); +} + +function Harness({ trapped }: { trapped: boolean }) { + return ( +
+ + {trapped && ( + + + + + )} +
+ ); +} + +describe("useFocusTrap", () => { + it("focuses the container itself on mount (so the dialog name is announced before its controls)", () => { + render(); + expect(document.activeElement).toBe(screen.getByTestId("dialog")); + }); + + it("wraps focus to the first element when Tab is pressed on the last", () => { + render(); + 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(); + 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 on unmount when nothing was focused on open", () => { + // Opened from a click that left focus on (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(); + expect(document.activeElement).toBe(document.body); + + rerender(); // trap focuses the container; restoreTo is + screen.getByTestId("outside").focus(); // focus subsequently moves elsewhere + + rerender(); // 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(); + screen.getByTestId("outside").focus(); + expect(document.activeElement).toBe(screen.getByTestId("outside")); + + rerender(); + expect(document.activeElement).toBe(screen.getByTestId("dialog")); + + rerender(); + expect(document.activeElement).toBe(screen.getByTestId("outside")); + }); +});
{col.header}{col.header} + +