mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-09-01 16:48:19 +00:00
feat: standardize trace view to be the same as packets view, etc
This commit is contained in:
+1
-1
@@ -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} />,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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} />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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
@@ -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
@@ -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[];
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user