Collapse the packet row to a single line with hops and endpoints

The header and the rows are separate grids, so the shared track list can't use ch or auto — each resolved them differently and the columns drifted apart. Observer moves into the expansion to free the column.
This commit is contained in:
MrAlders0n committed 2026-07-27 22:06:05 -04:00
1 parent d1ba42e692
commit 1f3fb5b7c9
11 files changed
+210 -204

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
import type { PacketSummary } from "../../types/api";
import type { PathConfidence } from "../../types/enums";
import { buildPathSummary, type PathChip } from "./path-summary";
// Same three-state vocabulary PathData uses in the analyzer.
const CONFIDENCE_CLASSES: Record<PathConfidence, string> = {
high: "bg-green/8 text-green",
ambiguous: "bg-warn/8 text-warn",
none: "bg-text-muted/8 text-text-dim",
};
function Chip({ chip }: { chip: PathChip }) {
if (chip.kind === "hex") {
return <span className="font-mono text-[10px] text-text-dim tracking-wider px-1">{chip.label}</span>;
}
if (chip.kind === "unresolved-run") {
return (
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm ${CONFIDENCE_CLASSES.none}`}>
{chip.count === 1 ? "?" : `?×${chip.count}`}
</span>
);
}
return (
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm truncate ${CONFIDENCE_CLASSES[chip.confidence]}`}>
{chip.label}
</span>
);
}
const Na = () => <span className="text-text-dim">n/a</span>;
// The packet's logical endpoints. beacon-server resolves these on the WS feed only and leaves them
// nil on the REST list, so scrollback rows read n/a — as do payload types with no addressed
// endpoint at all (GRP_TXT/GRP_DATA/TRACE).
export function PacketEndpoints({ packet }: { packet: PacketSummary }) {
const { source, destination } = buildPathSummary(packet);
// One n/a for the pair reads better than "n/a → n/a" on every historical row.
if (!source && !destination) return <Na />;
return (
<span className="flex items-center gap-x-1 min-w-0">
{source ? <Chip chip={source} /> : <Na />}
<span className="text-text-dim px-0.5" aria-hidden>→</span>
{destination ? <Chip chip={destination} /> : <Na />}
</span>
);
}
+7
View File
@@ -25,6 +25,7 @@ interface Props {
// the per-observer table, which does wait on usePacketDetail.
export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedObservationId, onSelectObservation }: Props) {
const { data, isLoading, isError, refetch } = usePacketDetail(packet.packetHash);
const observer = packet.latestObserver;
// firstHeardAt/lastHeardAt are epoch ms (same unit Timestamp expects), so the difference is
// already in ms for formatPropagation -- no *1000 here.
const spread = packet.lastHeardAt - packet.firstHeardAt;
@@ -43,6 +44,12 @@ export function PacketExpansion({ packet, onOpenAnalyzer, onViewPath, selectedOb
return (
<div data-testid="packet-expansion" className="bg-bg-surface border-l-2 border-primary pl-6 pr-3 py-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[10px] text-text-muted pb-2">
<span>
observer{" "}
{observer
? <span className="text-text-normal">{observer.displayName ?? observer.id.slice(0, 8)}</span>
: <span className="text-text-dim">n/a</span>}
</span>
<span>first <Timestamp value={packet.firstHeardAt} /></span>
<span>last <Timestamp value={packet.lastHeardAt} /></span>
<span>spread {formatPropagation(spread)}</span>
-57
View File
@@ -1,57 +0,0 @@
import type { PacketSummary } from "../../types/api";
import type { PathConfidence } from "../../types/enums";
import { buildPathSummary, type PathChip } from "./path-summary";
// Same three-state vocabulary PathData uses in the analyzer.
const CONFIDENCE_CLASSES: Record<PathConfidence, string> = {
high: "bg-green/8 text-green",
ambiguous: "bg-warn/8 text-warn",
none: "bg-text-muted/8 text-text-dim",
};
function Chip({ chip }: { chip: PathChip }) {
if (chip.kind === "hex") {
return <span className="font-mono text-[10px] text-text-dim tracking-wider px-1">{chip.label}</span>;
}
if (chip.kind === "unresolved-run") {
return (
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm ${CONFIDENCE_CLASSES.none}`}>
{chip.count === 1 ? "?" : `?×${chip.count}`}
</span>
);
}
return (
<span className={`font-mono text-[10px] px-1.5 py-px rounded-sm ${CONFIDENCE_CLASSES[chip.confidence]}`}>
{chip.label}
</span>
);
}
// Line 2 of a packet row: always the latest observation's path, never the selected one.
export function PacketPathLine({ packet }: { packet: PacketSummary }) {
const summary = buildPathSummary(packet);
if (summary.isNa) {
return <span className="text-[11px] text-text-dim">n/a</span>;
}
return (
<div className="flex items-center gap-x-1 text-[11px]">
<span className="text-text-dim uppercase tracking-wider text-[9px] mr-1">latest</span>
<span className="text-text-muted mr-2">{summary.hopLabel}</span>
{summary.chips.map((chip, i) => <Chip key={i} chip={chip} />)}
{summary.overflow > 0 && (
<span className="font-mono text-[10px] text-primary bg-primary/8 px-1.5 py-px rounded-sm">
+{summary.overflow} more
</span>
)}
{(summary.source || summary.destination) && (
<span className="flex items-center gap-x-1 ml-8">
{summary.source ? <Chip chip={summary.source} /> : <span className="text-text-dim">n/a</span>}
<span className="text-text-dim px-0.5" aria-hidden>→</span>
{summary.destination ? <Chip chip={summary.destination} /> : <span className="text-text-dim">n/a</span>}
</span>
)}
</div>
);
}
+2 -1
View File
@@ -12,7 +12,8 @@ export function PacketTableHeader() {
<span>Type</span>
<span>Route</span>
<span>Obs</span>
<span>Observer</span>
<span>Hops</span>
<span>Src → Dst</span>
<span>IATA</span>
<span className="text-right">Age</span>
</div>
+11 -11
View File
@@ -6,7 +6,7 @@ import { payloadTypeVariant } from "../../components/badge-utils";
import { PAYLOAD_TYPE_NAMES, type PayloadTypeValue } from "../../types/enums";
import type { PacketSummary } from "../../types/api";
import { GRID_TEMPLATE } from "./packet-grid";
import { PacketPathLine } from "./PacketPathLine";
import { PacketEndpoints } from "./PacketEndpoints";
interface PacketTableRowProps {
packet: PacketSummary;
@@ -15,10 +15,11 @@ interface PacketTableRowProps {
onToggle: () => void;
}
// two-line table row: line 1 is the identity grid (shares GRID_TEMPLATE with the header), line 2
// is the latest path. The whole row is the expansion click target.
// Single-line table row sharing GRID_TEMPLATE with the sticky header. The observer lives in the
// expansion instead, which frees the wide column for the packet's endpoints.
export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTableRowProps) {
const observer = packet.latestObserver;
// ?? not ||, so a legitimate 0-hop direct packet still shows its count
const hopCount = packet.latestObserver?.pathLength?.hopCount;
return (
<div
@@ -53,20 +54,19 @@ export function PacketTableRow({ packet, expanded, isFresh, onToggle }: PacketTa
{packet.scope && <ScopeTag>{packet.scope}</ScopeTag>}
</span>
<span className="font-mono text-text-muted">×{packet.observationCount}</span>
<span className="text-text-normal truncate">
{observer ? (observer.displayName ?? observer.id.slice(0, 8)) : <span className="text-text-dim">n/a</span>}
<span className="font-mono text-text-muted">
{hopCount ?? <span className="text-text-dim">n/a</span>}
</span>
<span className="min-w-0 overflow-hidden">
<PacketEndpoints packet={packet} />
</span>
<span className="font-mono font-bold text-primary tracking-wider">
{observer?.iata ?? <span className="text-text-dim font-normal">n/a</span>}
{packet.latestObserver?.iata ?? <span className="text-text-dim font-normal">n/a</span>}
</span>
<span className="text-right text-text-muted">
<Timestamp value={packet.lastHeardAt} />
</span>
</button>
<div className="px-3 pb-1.5 pl-[2.25rem]">
<PacketPathLine packet={packet} />
</div>
</div>
);
}
+2 -1
View File
@@ -54,7 +54,8 @@ export function PacketVirtualList({
const virtualizer = useVirtualizer({
count: packets.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64, // a collapsed two-line row; expanded ones are remeasured
// a collapsed row: one grid line on desktop, a taller card below md. Expanded rows are remeasured.
estimateSize: () => (isMobile ? 64 : 43),
overscan: 10,
getItemKey: (index) => packets[index]?.packetHash ?? index,
});
+5 -3
View File
@@ -1,3 +1,5 @@
// One track list shared by the sticky header and every row, so columns stay aligned. Line 2 spans
// all of it via grid-column: 1 / -1.
export const GRID_TEMPLATE = "1.25rem minmax(7ch,auto) 6rem 5rem 3rem minmax(8rem,1fr) 3.5rem 5rem";
// One track list shared by the sticky header and every row, so columns stay aligned. Keep every
// track font- and content-independent: the header and the rows are separate grids, so a `ch` track
// resolves against each one's own font size (9px vs 11px) and an `auto` track against its own text
// ("HASH" vs "4AE77F09") — either silently drifts the two apart.
export const GRID_TEMPLATE = "1.25rem 5rem 6rem 5rem 3rem 3rem minmax(6rem,1fr) 3.5rem 5rem";
@@ -0,0 +1,67 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PacketEndpoints } from "../../../src/features/packets/PacketEndpoints";
import type { LatestObserver, PacketSummary } from "../../../src/types/api";
const pkt = (observer?: LatestObserver): PacketSummary => ({
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
routeType: 1, routeTypeName: "FLOOD",
firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, latestObserver: observer,
});
const obs = (over: Partial<LatestObserver> = {}): LatestObserver => ({
id: "o1", iata: "YVR", pathLength: { raw: "00", hashSize: 1, hopCount: 0 }, ...over,
});
describe("PacketEndpoints", () => {
it("renders a single n/a when there is no observer at all", () => {
render(<PacketEndpoints packet={pkt()} />);
expect(screen.getByText("n/a")).toBeInTheDocument();
});
it("renders both endpoints with the arrow glyph between them", () => {
render(<PacketEndpoints packet={pkt(obs({
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
resolvedDestination: { confidence: "high", nodes: [{ id: "d", publicKey: "bb", name: "DstNode" }] },
}))} />);
expect(screen.getByText("SrcNode")).toBeInTheDocument();
expect(screen.getByText("DstNode")).toBeInTheDocument();
expect(screen.getByText("→")).toBeInTheDocument();
});
it("shows n/a for a missing endpoint while the present one still renders", () => {
render(<PacketEndpoints packet={pkt(obs({
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
}))} />);
expect(screen.getByText("SrcNode")).toBeInTheDocument();
expect(screen.getByText("n/a")).toBeInTheDocument();
});
// The REST list leaves both nil, so this is the common scrollback case — one n/a, no arrow.
it("collapses to a single n/a when both endpoints are absent", () => {
render(<PacketEndpoints packet={pkt(obs())} />);
expect(screen.getByText("n/a")).toBeInTheDocument();
expect(screen.queryByText("→")).not.toBeInTheDocument();
});
it("tints an ambiguous endpoint with the warn token", () => {
render(<PacketEndpoints packet={pkt(obs({
resolvedSource: { confidence: "ambiguous", nodes: [{ id: "n", publicKey: "ab", name: "Raven" }] },
}))} />);
expect(screen.getByText("Raven").className).toContain("text-warn");
});
it("tints a high-confidence endpoint with the green token", () => {
render(<PacketEndpoints packet={pkt(obs({
resolvedSource: { confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Falcon" }] },
}))} />);
expect(screen.getByText("Falcon").className).toContain("text-green");
});
it("renders a bare ? for an endpoint the backend could not resolve", () => {
render(<PacketEndpoints packet={pkt(obs({
resolvedSource: { confidence: "none", nodes: [] },
}))} />);
expect(screen.getByText("?")).toBeInTheDocument();
});
});
@@ -1,114 +0,0 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { PacketPathLine } from "../../../src/features/packets/PacketPathLine";
import type { PacketSummary } from "../../../src/types/api";
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
packetHash: "AA11", payloadType: 1, payloadTypeName: "ADVERT",
routeType: 1, routeTypeName: "FLOOD",
firstHeardAt: 0, lastHeardAt: 0, observationCount: 1, ...over,
});
describe("PacketPathLine", () => {
it("renders a single n/a when there is nothing to show", () => {
render(<PacketPathLine packet={pkt()} />);
expect(screen.getByText("n/a")).toBeInTheDocument();
});
it("renders hop label, hex chips and the overflow count", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "4e", hashSize: 1, hopCount: 14 },
pathBytes: "000102030405060708090a0b0c0d",
},
})} />);
expect(screen.getByText("14 hops")).toBeInTheDocument();
expect(screen.getByText("00")).toBeInTheDocument();
expect(screen.getByText("+9 more")).toBeInTheDocument();
});
it("tints an ambiguous hop with the warn token", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f",
resolvedPath: [{ confidence: "ambiguous", nodes: [{ id: "n", publicKey: "ab", name: "Raven" }] }],
},
})} />);
expect(screen.getByText("Raven").className).toContain("text-warn");
});
it("tints a high-confidence hop with the green token", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f",
resolvedPath: [{ confidence: "high", nodes: [{ id: "n", publicKey: "ab", name: "Falcon" }] }],
},
})} />);
expect(screen.getByText("Falcon").className).toContain("text-green");
});
it("renders both endpoints with the arrow glyph between them", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "00", hashSize: 1, hopCount: 0 },
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
resolvedDestination: { confidence: "high", nodes: [{ id: "d", publicKey: "bb", name: "DstNode" }] },
},
})} />);
expect(screen.getByText("SrcNode")).toBeInTheDocument();
expect(screen.getByText("DstNode")).toBeInTheDocument();
expect(screen.getByText("→")).toBeInTheDocument();
});
it("shows n/a for a missing endpoint while the present one still renders", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "00", hashSize: 1, hopCount: 0 },
resolvedSource: { confidence: "high", nodes: [{ id: "s", publicKey: "aa", name: "SrcNode" }] },
},
})} />);
expect(screen.getByText("SrcNode")).toBeInTheDocument();
expect(screen.getByText("n/a")).toBeInTheDocument();
});
it("omits the endpoint block entirely when both endpoints are absent", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "00", hashSize: 1, hopCount: 0 },
},
})} />);
expect(screen.queryByText("→")).not.toBeInTheDocument();
});
it("renders a bare ? for a single unresolved hop", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "41", hashSize: 1, hopCount: 1 }, pathBytes: "7f",
resolvedPath: [{ confidence: "none", nodes: [] }],
},
})} />);
expect(screen.getByText("?")).toBeInTheDocument();
});
it("collapses a run of unresolved hops into a single ?×N chip", () => {
render(<PacketPathLine packet={pkt({
latestObserver: {
id: "o1", iata: "YVR",
pathLength: { raw: "43", hashSize: 1, hopCount: 3 }, pathBytes: "7f7f7f",
resolvedPath: [
{ confidence: "none", nodes: [] },
{ confidence: "none", nodes: [] },
{ confidence: "none", nodes: [] },
],
},
})} />);
expect(screen.getByText("?×3")).toBeInTheDocument();
});
});
@@ -6,11 +6,16 @@ import { GRID_TEMPLATE } from "../../../src/features/packets/packet-grid";
describe("PacketTableHeader", () => {
it("declares every column heading", () => {
render(<PacketTableHeader />);
for (const h of ["Hash", "Type", "Route", "Obs", "Observer", "IATA", "Age"]) {
for (const h of ["Hash", "Type", "Route", "Obs", "Hops", "Src → Dst", "IATA", "Age"]) {
expect(screen.getByText(h)).toBeInTheDocument();
}
});
it("no longer heads an observer column, which moved into the expansion", () => {
render(<PacketTableHeader />);
expect(screen.queryByText("Observer")).not.toBeInTheDocument();
});
it("is hidden below md", () => {
const { container } = render(<PacketTableHeader />);
expect(container.firstElementChild?.className).toContain("hidden");
@@ -23,9 +28,17 @@ describe("PacketTableHeader", () => {
expect(el.style.gridTemplateColumns).toBe(GRID_TEMPLATE);
});
it("has exactly 8 cells, one per row column including the chevron spacer", () => {
it("has exactly 9 cells, one per row column including the chevron spacer", () => {
const { container } = render(<PacketTableHeader />);
expect(container.firstElementChild?.children).toHaveLength(8);
expect(container.firstElementChild?.children).toHaveLength(9);
});
// Regression: the header and the rows are two independent grids. A `ch` track resolves against
// each one's own font size (header 9px vs row 11px) and an `auto`/`min-content` track against its
// own content ("HASH" vs "4AE77F09"), so either kind silently drifts the columns apart.
it("sizes every track in font-independent units so both grids resolve identically", () => {
expect(GRID_TEMPLATE).not.toMatch(/\bch\b/);
expect(GRID_TEMPLATE).not.toMatch(/auto|min-content|max-content|fit-content/);
});
it("leaves the leading chevron-alignment cell unlabeled and hidden from screen readers", () => {
+53 -14
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent, within } from "@testing-library/react";
import { PacketTableRow } from "../../../src/features/packets/PacketTableRow";
import type { PacketSummary } from "../../../src/types/api";
import type { LatestObserver, PacketSummary, ResolvedHop } from "../../../src/types/api";
const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
packetHash: "AA11BB22", payloadType: 1, payloadTypeName: "ADVERT",
@@ -9,6 +9,19 @@ const pkt = (over: Partial<PacketSummary> = {}): PacketSummary => ({
firstHeardAt: 1700000000, lastHeardAt: 1700000000, observationCount: 3, ...over,
});
const node = (name: string): ResolvedHop => ({
confidence: "high",
nodes: [{ id: "n-1", name, publicKey: "aabbccdd" }],
});
// pathLength is what makes buildPathSummary produce endpoints at all, so it is always present here.
const observer = (
over: { hopCount?: number } & Partial<Pick<LatestObserver, "resolvedSource" | "resolvedDestination">> = {},
): LatestObserver => {
const { hopCount = 2, ...rest } = over;
return { id: "abcdef1234", iata: "YVR", pathLength: { raw: "1e", hashSize: 1, hopCount }, ...rest };
};
describe("PacketTableRow", () => {
it("exposes one button carrying the expansion state", () => {
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
@@ -23,27 +36,53 @@ describe("PacketTableRow", () => {
expect(onToggle).toHaveBeenCalledOnce();
});
it("renders line 2 even when there is no path data, so row height is constant", () => {
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
expect(screen.getAllByText("n/a")).toHaveLength(3);
it("is a single line, so the row height stays constant for the virtualizer", () => {
const { container } = render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
expect(container.querySelectorAll("button")).toHaveLength(1);
expect(screen.queryByText("latest")).not.toBeInTheDocument();
});
it("falls back to n/a in the observer and IATA cells when there is no observer", () => {
it("falls back to n/a in the hops, endpoint and IATA cells when there is no observer", () => {
render(<PacketTableRow packet={pkt()} expanded={false} onToggle={() => {}} />);
const line1 = within(screen.getByRole("button"));
expect(line1.getAllByText("n/a")).toHaveLength(2);
const row = within(screen.getByRole("button"));
expect(row.getAllByText("n/a")).toHaveLength(3);
});
it("falls back to the observer id when there is no display name", () => {
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
expect(screen.getByText("abcdef12")).toBeInTheDocument();
it("no longer shows the observer, which moved into the expansion", () => {
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", displayName: "Cypress Peak", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
expect(screen.queryByText("Cypress Peak")).not.toBeInTheDocument();
expect(screen.queryByText("abcdef12")).not.toBeInTheDocument();
expect(screen.getByText("YVR")).toBeInTheDocument();
});
it("prefers the observer display name over the id", () => {
render(<PacketTableRow packet={pkt({ latestObserver: { id: "abcdef1234", displayName: "Cypress Peak", iata: "YVR" } })} expanded={false} onToggle={() => {}} />);
expect(screen.getByText("Cypress Peak")).toBeInTheDocument();
expect(screen.queryByText("abcdef12")).not.toBeInTheDocument();
it("shows the hop count, which the REST list carries on every row", () => {
render(<PacketTableRow packet={pkt({ observationCount: 7, latestObserver: observer({ hopCount: 3 }) })} expanded={false} onToggle={() => {}} />);
expect(screen.getByText("3")).toBeInTheDocument();
});
it("renders resolved endpoints when the WS feed supplied them", () => {
render(
<PacketTableRow
packet={pkt({ latestObserver: observer({ resolvedSource: node("Laprairie"), resolvedDestination: node("YUL1") }) })}
expanded={false}
onToggle={() => {}}
/>,
);
expect(screen.getByText("Laprairie")).toBeInTheDocument();
expect(screen.getByText("YUL1")).toBeInTheDocument();
});
// The REST list leaves resolvedSource/Destination nil on purpose, so scrollback rows show one n/a
// for the pair rather than "n/a → n/a".
it("collapses the endpoint cell to a single n/a when neither endpoint resolved", () => {
render(<PacketTableRow packet={pkt({ latestObserver: observer({ hopCount: 2 }) })} expanded={false} onToggle={() => {}} />);
expect(screen.getAllByText("n/a")).toHaveLength(1);
});
it("still marks the missing half when only one endpoint resolved", () => {
render(<PacketTableRow packet={pkt({ latestObserver: observer({ resolvedSource: node("Laprairie") }) })} expanded={false} onToggle={() => {}} />);
expect(screen.getByText("Laprairie")).toBeInTheDocument();
expect(screen.getAllByText("n/a")).toHaveLength(1);
});
it("reflects the expanded state on the button and chevron", () => {