Show packet source and destination on the path map and analyzer

This commit is contained in:
MrAlders0n committed 2026-07-23 22:53:02 -04:00
1 parent 6af9d652aa
commit 88ae52bdf4
8 files changed
+194 -49

No files matched your search

+12 -3
View File
@@ -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));
}
+5 -15
View File
@@ -201,22 +201,12 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
</ColorAccentField>
)}
{/* 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 && (
<ColorAccentField field="pathData">
{detail.header.payloadType === PayloadType.TRACE ? (
<>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path SNR Data</div>
<div className="font-mono text-[13px] text-text-normal break-all">
{selectedObs.pathBytes.toUpperCase()}
</div>
</>
) : (
<>
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Data</div>
<PathData pathBytes={selectedObs.pathBytes} hashSize={selectedObs.pathLength.hashSize} resolvedPath={selectedObs.resolvedPath} onViewNode={onViewNode} />
</>
)}
<div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">Path Data</div>
<PathData pathBytes={selectedObs.pathBytes} hashSize={selectedObs.pathLength.hashSize} resolvedPath={selectedObs.resolvedPath} onViewNode={onViewNode} />
</ColorAccentField>
)}
@@ -232,7 +222,7 @@ export function PacketAnalyzerDrawer({ detail, selectedObservationId, onClose, o
{detail.parsedPayload && typeof detail.parsedPayload === "object" && Object.keys(detail.parsedPayload).length > 0 && (
<DrawerSection title="Payload Breakdown">
<div className="font-mono text-[13px]">
<PayloadBreakdown payload={detail.parsedPayload} resolvedRoute={detail.resolvedRoute} onViewNode={onViewNode} />
<PayloadBreakdown payload={detail.parsedPayload} resolvedRoute={detail.resolvedRoute} resolvedSource={selectedObs?.resolvedSource} resolvedDestination={selectedObs?.resolvedDestination} onViewNode={onViewNode} />
</div>
</DrawerSection>
)}
+39 -22
View File
@@ -13,6 +13,14 @@ interface PayloadProps {
payload: Record<string, unknown>;
}
// 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 <div className="text-text-dim text-xs font-medium uppercase tracking-wider mb-1">{children}</div>;
}
@@ -80,11 +88,11 @@ function EncryptedIndicator() {
);
}
function EncryptedEnvelope({ payload, headerSlot, children }: {
function EncryptedEnvelope({ payload, headerSlot, children, resolvedSource, resolvedDestination, onViewNode }: {
payload: Record<string, unknown>;
headerSlot?: ReactNode;
children: (decrypted: Record<string, unknown>) => 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 }: {
<div className="flex flex-col gap-1">
{destinationHash && (
<ColorAccentField field="destinationHash">
<span className="text-text-dim">To </span><HexBadge value={destinationHash} />
<span className="text-text-dim">To </span>
{resolvedDestination
? <ResolvedHopBlock hop={resolvedDestination} label={destinationHash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
: <HexBadge value={destinationHash} />}
<span className="text-text-dim"> ({destinationHash.length / 2}B)</span>
</ColorAccentField>
)}
{sourceHash && (
<ColorAccentField field="sourceHash">
<span className="text-text-dim">From </span><HexBadge value={sourceHash} />
<span className="text-text-dim">From </span>
{resolvedSource
? <ResolvedHopBlock hop={resolvedSource} label={sourceHash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
: <HexBadge value={sourceHash} />}
<span className="text-text-dim"> ({sourceHash.length / 2}B)</span>
</ColorAccentField>
)}
@@ -316,9 +330,9 @@ function GroupTextPayload({ payload }: PayloadProps) {
);
}
function TextPayload({ payload }: PayloadProps) {
function TextPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
return (
<EncryptedEnvelope payload={payload}>
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => (
<div className="flex flex-col gap-1">
{d.message != null && (
@@ -335,9 +349,9 @@ function TextPayload({ payload }: PayloadProps) {
);
}
function RequestPayload({ payload }: PayloadProps) {
function RequestPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
return (
<EncryptedEnvelope payload={payload}>
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => (
<div className="flex flex-col gap-1.5">
{d.requestTypeName != null && (
@@ -357,9 +371,9 @@ function RequestPayload({ payload }: PayloadProps) {
);
}
function ResponsePayload({ payload }: PayloadProps) {
function ResponsePayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
return (
<EncryptedEnvelope payload={payload}>
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => (
<div className="flex flex-col gap-1">
{d.tag != null && (
@@ -387,9 +401,9 @@ function AckPayload({ payload }: PayloadProps) {
);
}
function PathPayload({ payload }: PayloadProps) {
function PathPayload({ payload, ...endpoints }: PayloadProps & EndpointProps) {
return (
<EncryptedEnvelope payload={payload}>
<EncryptedEnvelope payload={payload} {...endpoints}>
{(d) => <PathDecryptedContent decrypted={d} />}
</EncryptedEnvelope>
);
@@ -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 (
<div className="flex flex-col gap-2.5">
{destination != null && (
<FieldRow label="Dest Hash">
<span className="text-text-normal">0x{destination.toString(16).toUpperCase().padStart(2, "0")}</span>
{resolvedDestination
? <ResolvedHopBlock hop={resolvedDestination} label={destLabel} onViewNode={onViewNode} showSnr={false} />
: <span className="text-text-normal">{destLabel}</span>}
</FieldRow>
)}
{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<string, unknown>;
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 <AdvertPayload payload={payload} />;
case "TRACE": return <TracePayload payload={payload} resolvedRoute={resolvedRoute} onViewNode={onViewNode} />;
case "GROUP_TEXT": return <GroupTextPayload payload={payload} />;
case "TEXT_MESSAGE": return <TextPayload payload={payload} />;
case "REQUEST": return <RequestPayload payload={payload} />;
case "RESPONSE": return <ResponsePayload payload={payload} />;
case "ANON_REQUEST": return <AnonReqPayload payload={payload} />;
case "TEXT_MESSAGE": return <TextPayload payload={payload} {...endpoints} />;
case "REQUEST": return <RequestPayload payload={payload} {...endpoints} />;
case "RESPONSE": return <ResponsePayload payload={payload} {...endpoints} />;
case "ANON_REQUEST": return <AnonReqPayload payload={payload} {...endpoints} />;
case "ACK": return <AckPayload payload={payload} />;
case "PATH": return <PathPayload payload={payload} />;
case "PATH": return <PathPayload payload={payload} {...endpoints} />;
case "CONTROL": return <ControlPayload payload={payload} />;
case "DISCOVER_REQ": return <DiscoverReqPayload payload={payload} />;
case "DISCOVER_RESP": return <DiscoverRespPayload payload={payload} />;
+4
View File
@@ -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 {
+3
View File
@@ -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;
};
};
}
+63 -9
View File
@@ -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<PacketDetail>,
);
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", () => {
@@ -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(
<MemoryRouter initialEntries={["/?tab=Packets"]}>
<PacketAnalyzerDrawer detail={traceDetail()} selectedObservationId={null} onClose={() => {}} />
</MemoryRouter>,
);
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);
});
});
@@ -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(<PayloadBreakdown payload={envelope} resolvedSource={resolvedSource} resolvedDestination={resolvedDestination} onViewNode={onViewNode} />);
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(<PayloadBreakdown payload={envelope} />);
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(<PayloadBreakdown payload={anon} resolvedDestination={resolvedDestination} onViewNode={onViewNode} />);
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.