mirror of
https://github.com/MeshCore-Beacon/beacon-web.git
synced 2026-10-23 13:22:28 +00:00
feat: standardize trace view to be the same as packets view, etc
This commit is contained in:
1 parent
16547ad4e2
commit
ed9741ea63
8 files changed
+115
-37
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user