{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.