diff --git a/src/App.tsx b/src/App.tsx index 7288de4..bfcf8b8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -190,7 +190,7 @@ function AppInner() { Routes: , // analyze opens the packet overlay (modal) rather than the side drawer, which suits the // master/detail layout and renders on any tab — same path NodeDetailPanel's onAnalyzePacket uses - Traces: , + Traces: , Channels: , Stats: , Map: , diff --git a/src/features/packets/PathData.tsx b/src/features/packets/PathData.tsx index 388520a..90f20e1 100644 --- a/src/features/packets/PathData.tsx +++ b/src/features/packets/PathData.tsx @@ -17,9 +17,10 @@ function nodeLabel(node: ResolvedNode): string { } // Portals to so the drawer's overflow doesn't clip it; a close delay bridges the mouse gap. -function HopPopover({ hop, onViewNode, children }: { +function HopPopover({ hop, onViewNode, showSnr = true, children }: { hop: ResolvedHop | undefined; onViewNode?: (nodeId: string) => void; + showSnr?: boolean; children: ReactNode; }) { const hasHover = useHasHover(); @@ -126,7 +127,7 @@ function HopPopover({ hop, onViewNode, children }: { ) : ( nodes.map((node) => {nodeLabel(node)}) )} - {hop?.snr != null && ( + {showSnr && hop?.snr != null && ( SNR {formatSnr(hop.snr)} @@ -139,10 +140,11 @@ function HopPopover({ hop, onViewNode, children }: { } // One hash block + its hop popover. Shared by PathData and the trace payload so both resolve identically. -export function ResolvedHopBlock({ hop, label, onViewNode }: { +export function ResolvedHopBlock({ hop, label, onViewNode, showSnr = true }: { hop: ResolvedHop | undefined; label: string; onViewNode?: (nodeId: string) => void; + showSnr?: boolean; }) { const hasHover = useHasHover(); const confidence: PathConfidence = hop?.confidence ?? "none"; @@ -150,7 +152,7 @@ export function ResolvedHopBlock({ hop, label, onViewNode }: { // mouse-only shortcut: a lone resolved match makes the block jump straight to the node (touch taps open the popover) const single = hasHover && hop && hop.nodes.length === 1 && onViewNode ? hop.nodes[0] : undefined; return ( - + {single ? ( + ); } @@ -61,12 +84,13 @@ interface TraceDetailPanelProps { tag: string; onClose: () => void; onAnalyze: (hash: string) => void; + onViewNode?: (nodeId: string) => void; } // Right-hand detail panel for a selected trace tag, matching the other entity tabs. The trace's // packets stand in for the packet analyzer's "Observations": a "Packets" section listing each packet, // any of which opens the packet analyzer. -export function TraceDetailPanel({ tag, onClose, onAnalyze }: TraceDetailPanelProps) { +export function TraceDetailPanel({ tag, onClose, onAnalyze, onViewNode }: TraceDetailPanelProps) { const { data: detail, isLoading } = useQuery({ queryKey: ["trace", tag], queryFn: () => getTraceDetail(tag), @@ -85,7 +109,7 @@ export function TraceDetailPanel({ tag, onClose, onAnalyze }: TraceDetailPanelPr {packets.length} packet{packets.length === 1 ? "" : "s"} {packets.map((pkt) => ( - + ))} ) : ( diff --git a/src/features/traces/TraceList.tsx b/src/features/traces/TraceList.tsx index b619b18..d815e43 100644 --- a/src/features/traces/TraceList.tsx +++ b/src/features/traces/TraceList.tsx @@ -14,6 +14,7 @@ const TRACE_LIST_LIMIT = 200; interface TraceListProps { onAnalyze: (hash: string | null) => void; + onViewNode?: (nodeId: string) => void; } // A trace tag as a selectable card, echoing PacketRow's look so the tab reads like the Packets tab. @@ -48,7 +49,7 @@ function TraceTagCard({ tag, selected, onSelect }: { ); } -export function TraceList({ onAnalyze }: TraceListProps) { +export function TraceList({ onAnalyze, onViewNode }: TraceListProps) { const { iatas, regionKey } = useRegion(); const [selectedTag, setSelectedTag] = useState(null); @@ -81,7 +82,7 @@ export function TraceList({ onAnalyze }: TraceListProps) { )} {selectedTag && ( - setSelectedTag(null)} onAnalyze={onAnalyze} /> + setSelectedTag(null)} onAnalyze={onAnalyze} onViewNode={onViewNode} /> )} ); diff --git a/src/types/api.ts b/src/types/api.ts index fc098b2..f2f0a39 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -42,8 +42,7 @@ export interface ResolvedHop { confidence: PathConfidence; nodes: ResolvedNode[]; // empty when confidence is "none" snr?: number; // per-hop link SNR (dB) when the backend resolved it - hashBytes?: string; // hex per-hop path-hash prefix. Not sent on trace hops (only RouteHop carries it); - // unresolved trace hops fall back to #position labels. + hashBytes?: string; // hex per-hop path-hash prefix, carried by RouteHop; trace hops get theirs from rawPath instead } export interface PathLength { @@ -183,6 +182,11 @@ export interface TraceTagSummary { iataCount: number; // distinct IATAs the tag was heard in } +export interface RawHop { + hash: string; // hex per-hop path-hash prefix + snr?: number; // per-hop link SNR (dB) when known +} + export interface TracePacket { packetHash: string; routeType: number; @@ -190,6 +194,7 @@ export interface TracePacket { scope?: string; // matched transport scope name, when any firstHeardAt: number; // epoch ms lastHeardAt: number; // epoch ms + rawPath: RawHop[]; // one hop per trace path hash, index-aligned with resolvedRoute resolvedRoute: ResolvedHop[]; // one hop per trace path hash; nodes empty when unresolved } diff --git a/src/types/ws.ts b/src/types/ws.ts index 2212b3a..def4e0c 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -1,4 +1,5 @@ import type { ChannelMessage } from "../features/channels/types"; +import type { NodeIATA } from "../features/nodes/types"; // individual server-sent message shapes @@ -62,12 +63,14 @@ export interface WsObserverStatus { data: { observerId: string; displayName: string; + observerType?: string; iata: string; online: boolean; + radio?: string; // compact "freq,bw,sf" string + scopes: string[]; batteryMv: number | null; uptimeSeconds: number | null; lastStatusAt: number; - fields: string[]; }; } @@ -77,12 +80,18 @@ export interface WsNodeUpdate { event: "nodeUpdate"; data: { nodeId: string; + publicKey: string; name: string; nodeType: number; + nodeTypeName: string; iata: string; // integer microdegrees, same as REST /nodes — the server sends one value to both (microToDeg scales it) lat?: number; lng?: number; + isObserver: boolean; + iatas: NodeIATA[]; + defaultScope?: string; + radio?: string; // compact "freq,bw,sf" string }; } @@ -127,6 +136,7 @@ export type WsServerMessage = export interface SubscriptionFilter { iatas?: string[]; regionIds?: string[]; + regionSlugs?: string[]; payloadTypes?: number[]; routeTypes?: number[]; channelHashes?: string[]; diff --git a/tests/features/traces/TraceList.test.tsx b/tests/features/traces/TraceList.test.tsx index b903b79..8d1bc8b 100644 --- a/tests/features/traces/TraceList.test.tsx +++ b/tests/features/traces/TraceList.test.tsx @@ -26,8 +26,8 @@ function tag(traceTag: string, packetCount = 1): TraceTagSummary { const detail: TraceDetail = { traceTag: "3f2a11c0", packets: [ - { packetHash: "hash-aaa", routeType: 1, routeTypeName: "ROUTE_REQUEST", firstHeardAt: 1, lastHeardAt: 2, resolvedRoute: [] }, - { packetHash: "hash-bbb", routeType: 1, routeTypeName: "ROUTE_REQUEST", firstHeardAt: 1, lastHeardAt: 2, resolvedRoute: [] }, + { packetHash: "hash-aaa", routeType: 1, routeTypeName: "ROUTE_REQUEST", firstHeardAt: 1, lastHeardAt: 2, rawPath: [], resolvedRoute: [] }, + { packetHash: "hash-bbb", routeType: 1, routeTypeName: "ROUTE_REQUEST", firstHeardAt: 1, lastHeardAt: 2, rawPath: [], resolvedRoute: [] }, ], }; @@ -79,7 +79,7 @@ describe("TraceList", () => { mockGetTraceDetail.mockResolvedValue({ traceTag: "3f2a11c0", packets: [ - { packetHash: "hash-aaa", routeType: 1, routeTypeName: "ROUTE_REQUEST", firstHeardAt: 1717689045001, lastHeardAt: 1717689045123, resolvedRoute: [] }, + { packetHash: "hash-aaa", routeType: 1, routeTypeName: "ROUTE_REQUEST", firstHeardAt: 1717689045001, lastHeardAt: 1717689045123, rawPath: [], resolvedRoute: [] }, ], }); @@ -111,6 +111,42 @@ describe("TraceList", () => { expect(onAnalyze).toHaveBeenCalledWith("hash-aaa"); }); + it("renders each hop's raw path-hash byte and surfaces resolved nodes in the popover", async () => { + mockGetTraces.mockResolvedValue([tag("3f2a11c0", 1)]); + mockGetTraceDetail.mockResolvedValue({ + traceTag: "3f2a11c0", + packets: [ + { + packetHash: "hash-aaa", + routeType: 1, + routeTypeName: "ROUTE_REQUEST", + firstHeardAt: 1, + lastHeardAt: 2, + rawPath: [{ hash: "a1", snr: -7.5 }, { hash: "b2" }], + resolvedRoute: [ + { confidence: "high", nodes: [{ id: "n1", name: "GatewayX", publicKey: "deadbeef" }] }, + { confidence: "none", nodes: [] }, + ], + }, + ], + }); + + renderTraces(); + fireEvent.click(await screen.findByText("3F2A11C0")); + + // raw bytes shown uppercase, like the packet path renderer + const hopA = await screen.findByText("A1"); + expect(hopA).toBeInTheDocument(); + expect(screen.getByText("B2")).toBeInTheDocument(); + + // per-hop SNR sits on a sub-line below the hash, like the TRACE payload view + expect(screen.getByText("-7.50 dB")).toBeInTheDocument(); + + // hovering a resolved hop reveals its candidate node + fireEvent.mouseEnter(hopA); + expect(await screen.findByRole("tooltip")).toHaveTextContent("GatewayX"); + }); + it("shows an empty state when there are no traces", async () => { mockGetTraces.mockResolvedValue([]);