diff --git a/src/features/map/packet-path.ts b/src/features/map/packet-path.ts index 9715fdb..13b4f8a 100644 --- a/src/features/map/packet-path.ts +++ b/src/features/map/packet-path.ts @@ -59,10 +59,19 @@ export function buildPacketPaths(detail: PacketDetail): PacketPath[] { raw.push({ key, label, propagationMs, points }); }; - for (const obs of detail.observations) { - add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(obs.resolvedPath)); + const isTrace = detail.header.payloadType === PayloadType.TRACE; + // TRACE observations now resolve to the same hops as detail.resolvedRoute, so their per-observation + // lines would just duplicate the single "Trace route" below — draw only that one for traces. + if (!isTrace) { + for (const obs of detail.observations) { + // full chain: source → relay hops → destination; missing/unlocated endpoints drop out in pathPoints + const chain = [obs.resolvedSource, ...obs.resolvedPath, obs.resolvedDestination].filter( + (h): h is ResolvedHop => h != null, + ); + add(obs.observerId, observerLabel(obs), obs.propagationTimeMs, pathPoints(chain)); + } } - if (detail.header.payloadType === PayloadType.TRACE && detail.resolvedRoute) { + if (isTrace && detail.resolvedRoute) { add("trace", "Trace route", undefined, pathPoints(detail.resolvedRoute)); } diff --git a/src/features/packets/PacketAnalyzerDrawer.tsx b/src/features/packets/PacketAnalyzerDrawer.tsx index 4301cc5..3ccaac2 100644 --- a/src/features/packets/PacketAnalyzerDrawer.tsx +++ b/src/features/packets/PacketAnalyzerDrawer.tsx @@ -201,22 +201,12 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o )} - {/* Path data — for TRACE the path bytes are per-hop SNR samples, so show them raw */} + {/* Path data — TRACE's pathBytes are now its trace path hashes (matching hashSize/hopCount + and resolvedPath), so it resolves through PathData like every other type. */} {selectedObs?.pathBytes && ( - {detail.header.payloadType === PayloadType.TRACE ? ( - <> -
Path SNR Data
-
- {selectedObs.pathBytes.toUpperCase()} -
- - ) : ( - <> -
Path Data
- - - )} +
Path Data
+
)} @@ -232,7 +222,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o {detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && (
- +
)} diff --git a/src/features/packets/payload-renderers.tsx b/src/features/packets/payload-renderers.tsx index e36e3cc..87902eb 100644 --- a/src/features/packets/payload-renderers.tsx +++ b/src/features/packets/payload-renderers.tsx @@ -13,6 +13,14 @@ interface PayloadProps { payload: Record; } +// the packet's resolved endpoints + node-open callback, threaded from the analyzer through the +// envelope/anon renderers so From/To/Dest hashes resolve to node blocks like path hops do. +interface EndpointProps { + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; + onViewNode?: (nodeId: string) => void; +} + function SectionLabel({ children }: { children: React.ReactNode }) { return
{children}
; } @@ -80,11 +88,11 @@ function EncryptedIndicator() { ); } -function EncryptedEnvelope({ payload, headerSlot, children }: { +function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, resolvedDestination, onViewNode }: { payload: Record; headerSlot?: ReactNode; children: (decrypted: Record) => ReactNode; -}) { +} & EndpointProps) { const destinationHash = payload.destinationHash as string | undefined; const sourceHash = payload.sourceHash as string | undefined; const cipherMac = payload.cipherMac as string | undefined; @@ -96,13 +104,19 @@ function EncryptedEnvelope({ payload, headerSlot, children }: {
{destinationHash && ( - To + To + {resolvedDestination + ? + : } ({destinationHash.length / 2}B) )} {sourceHash && ( - From + From + {resolvedSource + ? + : } ({sourceHash.length / 2}B) )} @@ -316,9 +330,9 @@ function GroupTextPayload({ payload }: PayloadProps) { ); } -function TextPayload({ payload }: PayloadProps) { +function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.message != null && ( @@ -335,9 +349,9 @@ function TextPayload({ payload }: PayloadProps) { ); } -function RequestPayload({ payload }: PayloadProps) { +function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.requestTypeName != null && ( @@ -357,9 +371,9 @@ function RequestPayload({ payload }: PayloadProps) { ); } -function ResponsePayload({ payload }: PayloadProps) { +function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => (
{d.tag != null && ( @@ -387,9 +401,9 @@ function AckPayload({ payload }: PayloadProps) { ); } -function PathPayload({ payload }: PayloadProps) { +function PathPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) { return ( - + {(d) => } ); @@ -652,15 +666,18 @@ function GenericPayload({ payload }: PayloadProps) { ); } -function AnonReqPayload({ payload }: PayloadProps) { +function AnonReqPayload({ payload, resolvedDestination, onViewNode }: PayloadProps & EndpointProps) { const destination = payload.destination as number | undefined; const ephemeralPubKey = payload.ephemeralPubKey as string | undefined; + const destLabel = destination != null ? `0x${destination.toString(16).toUpperCase().padStart(2, "0")}` : ""; return (
{destination != null && ( - 0x{destination.toString(16).toUpperCase().padStart(2, "0")} + {resolvedDestination + ? + : {destLabel}} )} {ephemeralPubKey && ( @@ -672,21 +689,21 @@ function AnonReqPayload({ payload }: PayloadProps) { // routes payload.type to the right renderer -export function PayloadBreakdown({ payload, resolvedRoute, onViewNode }: { +export function PayloadBreakdown({ payload, resolvedRoute, resolvedSource, resolvedDestination, onViewNode }: { payload: Record; resolvedRoute?: ResolvedHop[]; // trace packets only — packet-level, not part of parsedPayload - onViewNode?: (nodeId: string) => void; -}) { +} & EndpointProps) { + const endpoints: EndpointProps = { resolvedSource, resolvedDestination, onViewNode }; switch (payload.type) { case "ADVERT": return ; case "TRACE": return ; case "GROUP_TEXT": return ; - case "TEXT_MESSAGE": return ; - case "REQUEST": return ; - case "RESPONSE": return ; - case "ANON_REQUEST": return ; + case "TEXT_MESSAGE": return ; + case "REQUEST": return ; + case "RESPONSE": return ; + case "ANON_REQUEST": return ; case "ACK": return ; - case "PATH": return ; + case "PATH": return ; case "CONTROL": return ; case "DISCOVER_REQ": return ; case "DISCOVER_RESP": return ; diff --git a/src/types/api.ts b/src/types/api.ts index f4b68be..86dfa73 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -69,6 +69,10 @@ export interface Observation { }; sourceBroker: string; resolvedPath: ResolvedHop[]; + // the packet's logical endpoints, resolved from the payload; separate from the relay + // resolvedPath. Absent for payload types with no addressed endpoint (GRP_TXT/GRP_DATA/TRACE). + resolvedSource?: ResolvedHop; + resolvedDestination?: ResolvedHop; } export interface PacketHeader { diff --git a/src/types/ws.ts b/src/types/ws.ts index bbff970..ea65403 100644 --- a/src/types/ws.ts +++ b/src/types/ws.ts @@ -63,6 +63,9 @@ export interface WsPacketObservation { // per-hop resolved path; populated only when the connection opts in via configure{resolvePath}, // null otherwise. Same shape as the REST Observation.resolvedPath. resolvedPath?: ResolvedHop[] | null; + // the packet's logical endpoints, same shape as REST; null when the payload type carries none. + resolvedSource?: ResolvedHop | null; + resolvedDestination?: ResolvedHop | null; }; }; } diff --git a/tests/features/map/packet-path.test.ts b/tests/features/map/packet-path.test.ts index 79514eb..053f7ab 100644 --- a/tests/features/map/packet-path.test.ts +++ b/tests/features/map/packet-path.test.ts @@ -32,21 +32,75 @@ describe("buildPacketPaths", () => { expect(paths[1]).toMatchObject({ key: "obs-slow", propagationMs: 900, color: PATH_COLORS[1] }); // colors follow sort order }); - it("sorts missing propagation and the trace route last", () => { + it("sorts observations with missing propagation after timed ones", () => { + const d = detail([ + obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation + obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }), + ]); + const keys = buildPacketPaths(d).map((p) => p.key); + expect(keys[0]).toBe("obs-fast"); + expect(keys[keys.length - 1]).toBe("obs-none"); // missing propagation sorts last + }); + + it("prepends resolvedSource and appends resolvedDestination to the observation line", () => { + const d = detail([ + obs(1, [hop("relay", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]); + }); + + it("draws a source->destination line for a directed message with no relay hops", () => { + const d = detail([ + obs(1, [], { + observerId: "obs-1", propagationTimeMs: 50, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const paths = buildPacketPaths(d); + expect(paths).toHaveLength(1); + expect(paths[0]!.points.map((p) => p.id)).toEqual(["src", "dst"]); + }); + + it("dedupes a resolvedSource that matches the first relay hop", () => { + const d = detail([ + obs(1, [hop("src", -79, 43), hop("relay", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src", -79, 43), + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["src", "relay", "dst"]); + }); + + it("skips an unresolved endpoint rather than drawing a misleading line", () => { + const d = detail([ + obs(1, [hop("relay1", -79, 43), hop("relay2", -78, 44)], { + observerId: "obs-1", propagationTimeMs: 100, + resolvedSource: hop("src"), // unlocated — no coords + resolvedDestination: hop("dst", -77, 45), + }), + ]); + const [path] = buildPacketPaths(d); + expect(path!.points.map((p) => p.id)).toEqual(["relay1", "relay2", "dst"]); + }); + + it("draws only the trace route for TRACE packets, suppressing per-observation lines", () => { const d = detail( - [ - obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-none" }), // no propagation - obs(2, [hop("c", -80, 44), hop("d", -76, 46)], { observerId: "obs-fast", propagationTimeMs: 50 }), - ], + [obs(1, [hop("a", -79, 43), hop("b", -75, 45)], { observerId: "obs-1", propagationTimeMs: 100 })], { header: { payloadType: PayloadType.TRACE, routeType: 1 }, resolvedRoute: [hop("e", -81, 47), hop("f", -77, 48)], } as unknown as Partial, ); - const keys = buildPacketPaths(d).map((p) => p.key); - expect(keys[0]).toBe("obs-fast"); - expect(keys).toContain("obs-none"); - expect(keys[keys.length - 1]).toBe("trace"); // trace (no propagation) last + expect(buildPacketPaths(d).map((p) => p.key)).toEqual(["trace"]); }); it("omits observations that resolve to fewer than 2 located hops", () => { diff --git a/tests/features/packets/PacketAnalyzerDrawer.test.tsx b/tests/features/packets/PacketAnalyzerDrawer.test.tsx index 09ed12a..bbe4255 100644 --- a/tests/features/packets/PacketAnalyzerDrawer.test.tsx +++ b/tests/features/packets/PacketAnalyzerDrawer.test.tsx @@ -64,3 +64,35 @@ describe("PacketAnalyzerDrawer view-path button", () => { expect(screen.getByRole("button", { name: /view path on map/i })).toBeDisabled(); }); }); + +describe("PacketAnalyzerDrawer TRACE path data", () => { + // After beacon-server's trace-path fix (7a58a07) a TRACE observation's pathBytes are the trace's + // own path hashes (with matching hashSize/hopCount and a real resolvedPath), not raw SNR bytes — + // so it must render as resolved Path Data, not under the old "Path SNR Data" label. + function traceDetail(): PacketDetail { + return { + packetHash: "abcdef12", + header: { raw: "12", routeType: RouteType.FLOOD, routeTypeName: "FLOOD", payloadType: PayloadType.TRACE, payloadTypeName: "TRACE", payloadVersion: 1 }, + firstHeardAt: 0, lastHeardAt: 0, firstToLastMs: 0, observationCount: 1, + rawPayload: "", decrypted: false, + observations: [{ + id: 1, observerId: "obs12345", iata: "YYZ", heardAt: 0, sourceBroker: "b", + pathLength: { raw: "02", hashSize: 1, hopCount: 2 }, + pathBytes: "abcd", + resolvedPath: [hop("a", -79, 43), hop("b", -75, 45)], + }], + } as unknown as PacketDetail; + } + + it("renders TRACE path bytes as resolved Path Data, not raw 'Path SNR Data'", () => { + render( + + {}} /> + , + ); + expect(screen.queryByText("Path SNR Data")).not.toBeInTheDocument(); + expect(screen.getByText("Path Data")).toBeInTheDocument(); + // the first trace hash renders as a resolved hop block, tinted green for high confidence + expect(screen.getAllByText("AB").some((el) => el.className.includes("text-green"))).toBe(true); + }); +}); diff --git a/tests/features/packets/payload-renderers.test.tsx b/tests/features/packets/payload-renderers.test.tsx index 192829a..f759115 100644 --- a/tests/features/packets/payload-renderers.test.tsx +++ b/tests/features/packets/payload-renderers.test.tsx @@ -46,6 +46,42 @@ describe("PayloadBreakdown — trace resolvedRoute overlay", () => { }); }); +describe("PayloadBreakdown — resolved source/destination endpoints", () => { + const envelope = { + type: "TEXT_MESSAGE", + sourceHash: "aa", + destinationHash: "bb", + cipherMac: "0011", + ciphertext: "deadbeef", + decrypted: null, + }; + const resolvedSource: ResolvedHop = { confidence: "high", nodes: [{ id: "s1", publicKey: "aa", name: "Alice" }] }; + const resolvedDestination: ResolvedHop = { confidence: "high", nodes: [{ id: "d1", publicKey: "bb", name: "Bob" }] }; + + it("makes the resolved From/To hashes clickable node blocks", () => { + const onViewNode = vi.fn(); + render(); + fireEvent.click(screen.getByRole("button", { name: "BB" })); // To → destination node + expect(onViewNode).toHaveBeenCalledWith("d1"); + fireEvent.click(screen.getByRole("button", { name: "AA" })); // From → source node + expect(onViewNode).toHaveBeenCalledWith("s1"); + }); + + it("falls back to a plain hash badge when the endpoint did not resolve", () => { + render(); + expect(screen.queryByRole("button", { name: "BB" })).not.toBeInTheDocument(); + expect(screen.getByText("BB")).toBeInTheDocument(); + }); + + it("resolves an ANON_REQUEST destination hash to a node block", () => { + const onViewNode = vi.fn(); + const anon = { type: "ANON_REQUEST", destination: 0xbb, ephemeralPubKey: "cc" }; + render(); + fireEvent.click(screen.getByRole("button", { name: "0xBB" })); + expect(onViewNode).toHaveBeenCalledWith("d1"); + }); +}); + describe("PayloadBreakdown — DISCOVER_REQ", () => { // Backend emits DISCOVER as a top-level parsedPayload.type (not nested under CONTROL). // See beacon-server internal/ingest/packet.go parsedDiscoverReq.