diff --git a/frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx b/frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx index 0ac811b90..630d95c99 100644 --- a/frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx +++ b/frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx @@ -650,6 +650,13 @@ function GeneralTab({ adjData }: { adjData: AdjArchiveV2 }) { export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchiveV2 }) => { const boards = useMemo(() => extractBoards(adjData), [adjData]); + const [archive, setArchive] = useState({ data: adjData, revision: 0 }); + + // Reset the scenario for every newly loaded archive, even when board names + // are unchanged. Keep the active tab so users can compare commits in place. + if (archive.data !== adjData) { + setArchive({ data: adjData, revision: archive.revision + 1 }); + } // Lifted state for cross-tab navigation const [activeTab, setActiveTab] = useState("boards"); @@ -729,8 +736,7 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchiveV2 }) => { {/* No overflow-hidden here: it would trap ThroughputTab's sticky panel. */} - {/* key resets the TCP-connected defaults when a different board set loads */} - b.name).join(",")} boards={boards} /> + diff --git a/frontend/adj-view/src/adj/v2/ThroughputTab.tsx b/frontend/adj-view/src/adj/v2/ThroughputTab.tsx index a07766ae7..08c6bb98b 100644 --- a/frontend/adj-view/src/adj/v2/ThroughputTab.tsx +++ b/frontend/adj-view/src/adj/v2/ThroughputTab.tsx @@ -333,10 +333,14 @@ function FlowTable({ title, flows, onPeriodChange, + selectedKey, + onSelectFlow, }: { title: string; flows: TrafficFlow[]; onPeriodChange?: OnPeriodChange; + selectedKey?: string; + onSelectFlow?: (flow: TrafficFlow | null) => void; }) { const [open, setOpen] = useState(null); const directions = (["up", "down"] as const).filter((d) => flows.some((f) => f.direction === d)); @@ -347,6 +351,7 @@ function FlowTable({

{title}

{flows.length} {flows.length === 1 ? "flow" : "flows"}, click one to see its calculation + {onSelectFlow && " and highlight its bandwidth"} @@ -364,20 +369,31 @@ function FlowTable({ {flows.map((f) => { const isOpen = open === f.key; + const isSelected = selectedKey === f.key; + const toggle = () => { + setOpen(isOpen ? null : f.key); + onSelectFlow?.(isSelected ? null : f); + }; return ( setOpen(isOpen ? null : f.key)} - className={cn("hover:bg-muted/40 cursor-pointer border-b last:border-0", isOpen && "bg-muted/40")} + onClick={toggle} + className={cn("hover:bg-muted/40 cursor-pointer border-b last:border-0", isOpen && "bg-muted/40", isSelected && "bg-primary/10")} > {onPeriodChange ? ( @@ -428,6 +444,17 @@ interface Segment { color: string; udp: number; keepAlive: number; + highlight?: { offset: number; bps: number; label: string }; +} + +function packetHighlight(board: BoardThroughput, key?: string): Segment["highlight"] { + const index = board.packets.findIndex((p) => p.key === key); + const packet = board.packets[index]; + return packet ? { + offset: board.packets.slice(0, index).reduce((sum, p) => sum + p.wireBps, 0), + bps: packet.wireBps, + label: `${board.board} / ${packet.name} #${packet.id}`, + } : undefined; } function StackedBar({ title, segments, capacityBps }: { title: string; segments: Segment[]; capacityBps: number }) { @@ -436,6 +463,7 @@ function StackedBar({ title, segments, capacityBps }: { title: string; segments: // Over capacity, scale to the total so every board still shows. const scale = Math.max(capacityBps, total); const visible = segments.filter((s) => s.udp + s.keepAlive > 0); + const highlighted = segments.find((s) => s.highlight)?.highlight; return (
@@ -453,8 +481,24 @@ function StackedBar({ title, segments, capacityBps }: { title: string; segments:
- {s.udp > 0 &&
} - {s.keepAlive > 0 &&
} + {s.udp > 0 && ( +
+
+ {s.highlight && ( +
+ )} +
+ )} + {s.keepAlive > 0 &&
}
@@ -470,6 +514,11 @@ function StackedBar({ title, segments, capacityBps }: { title: string; segments: })} {!over &&
}
+ {highlighted && ( +

+ {highlighted.label}: {formatBitrate(highlighted.bps)} · {pctOf(highlighted.bps, capacityBps)} of the link +

+ )}
{over ? ( @@ -487,6 +536,79 @@ function StackedBar({ title, segments, capacityBps }: { title: string; segments: ); } +// Each row is normalized to the board's traffic, rather than link capacity. +function PacketShareBar({ title, flows, colors, selectedKey, onSelectFlow }: { + title: string; + flows: TrafficFlow[]; + colors: ReadonlyMap; + selectedKey?: string; + onSelectFlow: (flow: TrafficFlow | null) => void; +}) { + const total = flows.reduce((sum, f) => sum + f.wireBps, 0); + const highlighted = flows.some((f) => f.key === selectedKey); + const label = (f: TrafficFlow) => `${f.name}${f.id == null ? "" : ` #${f.id}`}`; + return ( +
+
+

{title}

+ {formatBitrate(total)}{total > 0 && " · 100% of this traffic"} +
+ {total > 0 ? ( + <> +
+ {flows.filter((f) => f.wireBps > 0).map((f) => { + const selected = selectedKey === f.key; + const color = colors.get(f.key)!; + return ( + + +
+ {f.transport === "UDP" && ( +
+
+ + {label(f)} · {formatBitrate(f.wireBps)} · {pctOf(f.wireBps, total)} + +
+ ); + })} +
+
+ {flows.map((f) => ( + + + {f.transport === "UDP" ? ( + + ) : {label(f)} · {pctOf(f.wireBps, total)}} + + ))} +
+ + ) :

No traffic included in this direction.

} +
+ ); +} + // ─── scenario panel ────────────────────────────────────────────────────────── function Group({ title, children }: { title: string; children: ReactNode }) { @@ -588,10 +710,12 @@ export function ThroughputTab({ boards }: { boards: BoardMeta[] }) { const [options, setOptions] = useState(DEFAULT_TRAFFIC_OPTIONS); const [view, setView] = useState("direction"); const [capacityMbps, setCapacityMbps] = useState("100"); + const [maxCapacity, setMaxCapacity] = useState(false); const [backendMs, setBackendMs] = useState("50"); const [boardMs, setBoardMs] = useState("50"); const [expanded, setExpanded] = useState(null); const [periodOverrides, setPeriodOverrides] = useState({}); + const [selectedPacket, setSelectedPacket] = useState<{ board: string; key: string } | null>(null); const rows = useMemo( () => @@ -619,7 +743,15 @@ export function ThroughputTab({ boards }: { boards: BoardMeta[] }) { ); const sum = (pick: (t: BoardTotals) => number) => rows.reduce((s, r) => s + pick(totals.get(r.board)!), 0); - const capacityBps = Number(capacityMbps) > 0 ? Number(capacityMbps) * 1e6 : null; + const maxCapacityBps = view === "direction" + ? Math.max(sum((t) => t.up), sum((t) => t.down)) + : sum((t) => t.up + t.down); + const capacityBps = maxCapacity + ? Math.max(maxCapacityBps, 1) + : Number(capacityMbps) > 0 ? Number(capacityMbps) * 1e6 : null; + const displayedCapacityMbps = maxCapacity + ? (maxCapacityBps / 1e6).toPrecision(4) + : capacityMbps; const modifiedCount = (board: string) => Object.keys(periodOverrides[board] ?? {}).length; const totalModified = Object.values(periodOverrides).reduce((s, m) => s + Object.keys(m).length, 0); @@ -646,9 +778,13 @@ export function ThroughputTab({ boards }: { boards: BoardMeta[] }) { const other: Segment = { key: "__other", label: "Other boards", color: seriesColor(SERIES_SLOTS), udp: 0, keepAlive: 0 }; rows.forEach((r, i) => { const t = totals.get(r.board)!; + const highlight = selectedPacket?.board === r.board && udp(t) > 0 + ? packetHighlight(r, selectedPacket.key) + : undefined; if (i < SERIES_SLOTS) { - segments.push({ key: r.board, label: r.board, color: seriesColor(i), udp: udp(t), keepAlive: keepAlive(t) }); + segments.push({ key: r.board, label: r.board, color: seriesColor(i), udp: udp(t), keepAlive: keepAlive(t), highlight }); } else { + if (highlight) other.highlight = { ...highlight, offset: other.udp + highlight.offset }; other.udp += udp(t); other.keepAlive += keepAlive(t); } @@ -679,16 +815,22 @@ export function ThroughputTab({ boards }: { boards: BoardMeta[] }) { - + { setCapacityMbps(value); setMaxCapacity(false); }} + />
{CAPACITY_PRESETS.map((p) => ( ))} +
+ {maxCapacity && ( +

+ Scales to the highest current chart total and updates with the scenario. +

+ )}
@@ -811,6 +969,11 @@ export function ThroughputTab({ boards }: { boards: BoardMeta[] }) { capacityBps={capacityBps} /> )} + {selectedPacket && options.udp && ( + + )}
{rows.map((r, i) => ( @@ -861,7 +1024,7 @@ export function ThroughputTab({ boards }: { boards: BoardMeta[] }) {
@@ -948,7 +1114,10 @@ function BoardDetail({ kaFlows, totals, capacityBps, + view, onPeriodChange, + selectedKey, + onSelectFlow, }: { board: BoardThroughput; options: TrafficOptions; @@ -956,11 +1125,22 @@ function BoardDetail({ kaFlows: TrafficFlow[]; totals: BoardTotals; capacityBps: number | null; + view: View; onPeriodChange: OnPeriodChange; + selectedKey?: string; + onSelectFlow: (flow: TrafficFlow | null) => void; }) { const note = (text: string) => (

{text}

); + const udpFlows = [...board.packets].sort((a, b) => a.id! - b.id!); + const flows = [ + ...(options.udp ? udpFlows : []), + ...(options.keepAlive && connected ? kaFlows : []), + ]; + // Keep packet colors stable when traffic options are toggled. + const colors = new Map(udpFlows.map((f, i) => [f.key, seriesColor(i % SERIES_SLOTS)])); + for (const f of kaFlows) colors.set(f.key, "var(--series-other)"); return (
@@ -987,6 +1167,8 @@ function BoardDetail({ title="UDP data packets" flows={[...board.packets].sort((a, b) => a.id! - b.id!)} onPeriodChange={onPeriodChange} + selectedKey={selectedKey} + onSelectFlow={onSelectFlow} /> ) : note("This board has no periodic UDP packets.")} @@ -1017,6 +1199,25 @@ function BoardDetail({ ))}
)} +
+

Packet share of {board.board} traffic. Each row represents 100% of its total.

+ f.direction === "up") : flows} + colors={colors} + selectedKey={selectedKey} + onSelectFlow={onSelectFlow} + /> + {view === "direction" && ( + f.direction === "down")} + colors={colors} + selectedKey={selectedKey} + onSelectFlow={onSelectFlow} + /> + )} +
); }
- + {DIRECTION_LABEL[f.direction]}