feat: standardize trace view to be the same as packets view, etc

This commit is contained in:
MrAlders0n
2026-06-09 20:37:57 -04:00
parent 16547ad4e2
commit ed9741ea63
8 changed files with 115 additions and 37 deletions
+1 -1
View File
@@ -190,7 +190,7 @@ function AppInner() {
Routes: <RouteTable />,
// 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: <TraceList onAnalyze={setOverlayPacketHash} />,
Traces: <TraceList onAnalyze={setOverlayPacketHash} onViewNode={setOverlayNodeId} />,
Channels: <ChannelList wsManager={wsManager} onAnalyze={handleAnalyze} />,
Stats: <StatsOverview />,
Map: <MapView wsManager={wsManager} selectedNodeId={selectedNodeId} onSelectNode={setSelectedNodeId} />,
+6 -4
View File
@@ -17,9 +17,10 @@ function nodeLabel(node: ResolvedNode): string {
}
// Portals to <body> 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) => <span key={node.id}>{nodeLabel(node)}</span>)
)}
{hop?.snr != null && (
{showSnr && hop?.snr != null && (
<span className="text-text-dim">
SNR <span className={SIGNAL_LEVEL_CLASSES[snrLevel(hop.snr) ?? "bad"]}>{formatSnr(hop.snr)}</span>
</span>
@@ -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 (
<HopPopover hop={hop} onViewNode={onViewNode}>
<HopPopover hop={hop} onViewNode={onViewNode} showSnr={showSnr}>
{single ? (
<button
type="button"
+1 -1
View File
@@ -252,7 +252,7 @@ function TracePayload({ payload, resolvedRoute, onViewNode }: PayloadProps & {
{i > 0 && <span className="text-text-dim" aria-hidden></span>}
<span className="inline-flex flex-col items-center gap-0.5">
{resolvedRoute ? (
<ResolvedHopBlock hop={resolved} label={hash.toUpperCase()} onViewNode={onViewNode} />
<ResolvedHopBlock hop={resolved} label={hash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
) : (
<HexBadge value={hash} />
)}
+47 -23
View File
@@ -5,26 +5,36 @@ import { Badge } from "../../components/Badge";
import { Timestamp } from "../../components/Timestamp";
import { ResolvedHopBlock } from "../packets/PathData";
import { ScopeTag } from "../../components/ScopeTag";
import type { ResolvedHop, TracePacket } from "../../types/api";
import { formatSnr, snrLevel, SIGNAL_LEVEL_CLASSES } from "../../lib/formatters";
import type { RawHop, ResolvedHop, TracePacket } from "../../types/api";
// A trace packet's resolved route, reusing the packet path renderer's hop block. Resolved hops are
// labelled by node. The backend doesn't send per-hop hash bytes on trace routes yet (see hashBytes on
// ResolvedHop), so unresolved hops currently fall back to their #position in the path.
function TraceHopChain({ hops }: { hops: ResolvedHop[] }) {
if (hops.length === 0) return <span className="text-text-dim text-[11px] font-mono">no path</span>;
// A trace packet's path, rendered exactly like the TRACE payload view: the raw path-hash byte as the
// label, tinted by resolution confidence with candidate nodes in the popover, and the per-hop SNR on a
// sub-line below it (a "-" placeholder keeps the row aligned). rawPath and resolvedRoute are
// index-aligned (one entry per hash).
function TraceHopChain({ rawPath, resolvedRoute, onViewNode }: {
rawPath: RawHop[];
resolvedRoute: ResolvedHop[];
onViewNode?: (nodeId: string) => void;
}) {
if (rawPath.length === 0) return <span className="text-text-dim text-[11px] font-mono">no path</span>;
return (
<div className="flex flex-wrap items-center gap-1 font-mono text-[13px]">
{hops.map((hop, i) => {
const node = hop.nodes[0];
const label = node
? (node.name ?? node.publicKey.slice(0, 8))
: hop.hashBytes
? hop.hashBytes.toUpperCase()
: `#${i + 1}`;
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-2 font-mono text-[13px]">
{rawPath.map((raw, i) => {
const snr = raw.snr;
const level = snr != null ? snrLevel(snr) : null;
const sigClass = level ? SIGNAL_LEVEL_CLASSES[level] : "text-text-normal";
return (
<span key={i} className="contents">
{i > 0 && <span className="text-text-dim" aria-hidden></span>}
<ResolvedHopBlock hop={hop} label={label} />
<span className="inline-flex flex-col items-center gap-0.5">
<ResolvedHopBlock hop={resolvedRoute[i]} label={raw.hash.toUpperCase()} onViewNode={onViewNode} showSnr={false} />
{snr != null ? (
<span className={`text-[11px] ${sigClass}`}>{formatSnr(snr)} dB</span>
) : (
<span className="text-[11px] text-text-dim" aria-hidden>-</span>
)}
</span>
</span>
);
})}
@@ -34,12 +44,25 @@ function TraceHopChain({ hops }: { hops: ResolvedHop[] }) {
// One packet in the selected trace. Clicking it opens the shared packet analyzer (the same overlay the
// other tabs use), so a trace packet drills into observations exactly like any packet elsewhere.
function TracePacketRow({ pkt, onAnalyze }: { pkt: TracePacket; onAnalyze: (hash: string) => void }) {
function TracePacketRow({ pkt, onAnalyze, onViewNode }: {
pkt: TracePacket;
onAnalyze: (hash: string) => void;
onViewNode?: (nodeId: string) => void;
}) {
// A div, not a button: the hop popover nests clickable node buttons, so the row can't itself be a
// button. Mirrors TraceTagCard's role/tabIndex/onKeyDown; hop clicks stopPropagation so they don't analyze.
return (
<button
type="button"
className="w-full text-left rounded-md border border-border bg-bg-base px-3 py-2 cursor-pointer hover:border-text-dim/30 hover:bg-bg-raised/50 transition-colors"
<div
role="button"
tabIndex={0}
className="rounded-md border border-border bg-bg-base px-3 py-2 cursor-pointer hover:border-text-dim/30 hover:bg-bg-raised/50 transition-colors"
onClick={() => onAnalyze(pkt.packetHash)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onAnalyze(pkt.packetHash);
}
}}
>
<div className="flex items-center gap-2 text-[11px] text-text-dim">
<Badge variant="default">{pkt.routeTypeName || "Unknown"}</Badge>
@@ -51,9 +74,9 @@ function TracePacketRow({ pkt, onAnalyze }: { pkt: TracePacket; onAnalyze: (hash
<Field label="Last" value={<Timestamp value={pkt.lastHeardAt} ms />} />
</div>
<div className="mt-1.5">
<TraceHopChain hops={pkt.resolvedRoute} />
<TraceHopChain rawPath={pkt.rawPath} resolvedRoute={pkt.resolvedRoute} onViewNode={onViewNode} />
</div>
</button>
</div>
);
}
@@ -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"}
</span>
{packets.map((pkt) => (
<TracePacketRow key={pkt.packetHash} pkt={pkt} onAnalyze={onAnalyze} />
<TracePacketRow key={pkt.packetHash} pkt={pkt} onAnalyze={onAnalyze} onViewNode={onViewNode} />
))}
</div>
) : (
+3 -2
View File
@@ -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<string | null>(null);
@@ -81,7 +82,7 @@ export function TraceList({ onAnalyze }: TraceListProps) {
)}
</div>
{selectedTag && (
<TraceDetailPanel tag={selectedTag} onClose={() => setSelectedTag(null)} onAnalyze={onAnalyze} />
<TraceDetailPanel tag={selectedTag} onClose={() => setSelectedTag(null)} onAnalyze={onAnalyze} onViewNode={onViewNode} />
)}
</div>
);
+7 -2
View File
@@ -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
}
+11 -1
View File
@@ -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[];
+39 -3
View File
@@ -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([]);