diff --git a/frontend/adj-view/CLAUDE.md b/frontend/adj-view/CLAUDE.md new file mode 100644 index 000000000..c248655aa --- /dev/null +++ b/frontend/adj-view/CLAUDE.md @@ -0,0 +1,102 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Commands + +```bash +pnpm dev # Dev server on port 9004 +pnpm build # tsc -b && vite build +pnpm lint # ESLint +pnpm preview # Preview production build +``` + +Run from the monorepo root (`/software`) targeting this workspace instead, if not already inside `frontend/adj-view`: + +```bash +pnpm dev --filter adj-view +pnpm build:adj-view +pnpm add --filter adj-view +``` + +> **pnpm only** — the `preinstall` script enforces this via `only-allow`. + +There is no test suite for this workspace (`pnpm test` at the root skips it) — don't add one unless asked. Verify changes with `pnpm build` (type-checks via `tsc -b`) and `pnpm lint`; the only expected lint warning is the pre-existing `react-refresh/only-export-components` on `extractBoards` in `src/adj/v2/AdjViewerTabs.tsx`. + +For UI changes, look at the result: Playwright and its Chromium are installed for the root `e2e` workspace, so a throwaway script (outside the repo) can open `http://localhost:9004/?commit=` against `pnpm dev`, click through and screenshot. Archives are fetched live from GitHub Pages, so any real ADJ commit SHA works. + +## Architecture + +`adj-view` is a standalone Vite+React workspace in the Hyperloop Control Station monorepo (`frontend/`). Unlike the other frontend views (`testing-view`, `competition-view`, `logging-view`), it has **no WebSocket connection, no Zustand store, and no session dependency** — it's a pure fetch-and-browse tool for ADJ archives. Everything the app knows comes from either a commit hash the user types in or a `?commit=` query param. + +### What an ADJ archive is + +"ADJ" (see [Hyperloop-UPV/ADJ](https://github.com/hyperloop-upv/adj)) is the pod's telemetry/board definition format: boards, their measurements (typed variables with units/enum values), packets (periodic telemetry) and orders (commands), and network sockets. `adj-view` fetches a JSON snapshot of this archive, built and published per-commit to GitHub Pages, and renders it for inspection. The v2 archive's shape is documented via inline comments in `src/adj/v2/types.ts` — read that file before touching parsing logic; the JSON has non-obvious nesting (e.g. `boards[boardName]` is a group containing both the board's own config *and* sibling keys like `${boardName}_measurements`, `packets`, `orders`, `sockets` — see `extractBoards` in `src/adj/v2/AdjViewerTabs.tsx` for how it's flattened). + +### Versioning + +The ADJ format is versioned, and adj-view must keep rendering every version it has supported. The version is read from the archive's **top-level `version` key** (`{ version, boards, general_info }`). **If it's absent, the archive is v2** — every archive published before versioning existed has no such key. Version-specific code lives in `src/adj/vN/`; nothing outside `src/adj/` may import from a `vN/` folder directly. + +`src/adj/index.ts` is the only place that knows which versions exist: `detectAdjVersion` → `parseAdj` returns a `ParsedAdj` — either `{ supported: true, adj: LoadedAdj }` (a `{ version, data }` tagged union that `summarizeAdj` / `AdjViewer` in `src/adj/AdjViewer.tsx` switch on) or `{ supported: false, version }`. An unknown-but-well-formed version is **not** an error: the header's version badge turns amber and `src/components/UnsupportedAdjNotice.tsx` replaces the tabs, explaining the archive uses a newer ADJ format. Only a malformed `version` value (non-integer, boolean, empty string) throws. + +To add a version N: create `src/adj/vN/` (types, viewer, summary), add `{ version: N; data: ... }` to `LoadedAdj`, add `case N` to `parseAdj`, and add N to `SUPPORTED_ADJ_VERSIONS` (used by the notice's text). The `assertNever(adj.version)` defaults then make `tsc` fail until `summarizeAdj` and `AdjViewer` handle it too. + +Two external data sources, both configured in `config.ts`: +- **Archive JSON**: `https://hyperloop-upv.github.io/ADJ-Archive/storage/commit-.json` — fetched directly by commit hash, no auth. +- **GitHub API**: branch list and branch→commit resolution against `config.ADJ_GITHUB_REPO` (`hyperloop-upv/adj`), unauthenticated (rate-limited). + +### Component structure + +- `src/components/AdjViewerPage.tsx` — version-agnostic top-level page. Owns commit-hash input, branch combobox (via `useBranches`), fetch/loading/error state, and dark-mode toggle passed down from `App.tsx`. Reads `?commit=` on mount to support being launched from `logging-view`'s "View ADJ" shortcut. Only ever handles `LoadedAdj` — never a version-specific type — and renders `` once data is loaded. +- `src/adj/v2/AdjViewerTabs.tsx` — the v2 browser: Boards / Measurements / Packets / Network / Sockets / Throughput / General tabs, all fed by `extractBoards()`. Tabs share cross-navigation state lifted into this component (e.g. clicking a board in the Boards tab, or a packet in the Packets or Sockets tab, jumps to Measurements pre-filtered by board/variable IDs — see `handleJumpToMeasurements` / `handleJumpToPacketMeasurements`). This file is large and holds most of the UI logic. The list-tab atoms (`Highlight`, `SearchInput`, `SortableHeader`, `BoardChip`, `ResultCount`, `EmptyState`) live in `src/adj/v2/ui.tsx` and the `/`-to-search hook in `useKeyboardSearch.ts`, so tabs in their own files can reuse them. +- `src/adj/v2/NetworkTab.tsx` — hand-rolled SVG network topology diagram (boards on the left, `general_info.addresses` on the right, arrows colored by protocol derived from socket class names). No graph library is used or present in the monorepo; the node/edge count is small enough that manual two-column layout was simpler. See the file's header comments for the resolution rules used to match a socket's `remote_ip` to a board vs. a known address vs. an unknown external IP. +- `src/adj/v2/SocketsTab.tsx` — every socket in the ADJ as a filterable/sortable table: board side (`board_ip:port`), resolved remote, matching `general_info.ports` name, and the packets/orders that name the socket (expandable, jump to Measurements). Flags duplicate socket names, remote IPs that are neither a board nor a known address, and packets that reference a socket their board doesn't define. +- `src/adj/v2/sockets.ts` — the single place that interprets an `AdjSocket`, shared by Network, Sockets and Throughput: protocol and role, the board-side port (`boardPort`), `resolveTarget` for `remote_ip` (board / address key / unknown IP) and `portNames`. The ADJ schema (adj repo, `.github/workflows/scripts/adj-tester/schema/socket.schema.json`) has three types: `ServerSocket` (TCP server, `port`), `DatagramSocket` (UDP, `port` + `remote_ip`) and `Socket` (TCP client, `local_port` + `remote_ip` + `remote_port`); `main` only uses the first two. +- `src/adj/v2/ThroughputTab.tsx` (+ `throughput.ts`, `ThroughputHelp.tsx`) — bandwidth estimate per board; see [Throughput tab](#throughput-tab). +- `src/hooks/useBranches.ts` — fetches the branch list from GitHub, using `useTransition` (not manual loading state) and `AbortSignal.any` to combine an external abort with a fetch timeout. + +### Throughput tab + +A what-if estimate of each board's link usage, computed only from the ADJ and the backend's wire format (nothing is measured). All the maths lives in `src/adj/v2/throughput.ts` as pure functions; the UI never computes sizes itself. + +**Model.** Everything is a `TrafficFlow`: a periodic stream of identical frames with a direction ("up" = board → backend, "down" = backend → board). +- **UDP data**: packets whose `socket` names one of the board's `DatagramSocket` sockets and that have a `period` (unit from `period_type`, converted via `PERIOD_UNIT_SECONDS`: ns/us/ms/s; unknown unit → packet listed as "not counted"). Payload mirrors the backend codec (`backend/pkg/transport/presentation/decoder.go`, `pkg/transport/packet/data/codec.go`): one packet per datagram = `uint16` ID + variables packed in ADJ order, enum and bool = 1 B. Orders and period-less packets are excluded. +- **TCP keep-alive**: the backend's empty ID-1 packet (`backend/pkg/transport/keepalive.go`, 2 B payload, `tcp.keep_alive_interval_ms` = 50 ms in `cmd/config.toml`), sent both ways, only for boards ticked "TCP connected" (stands in for `[vehicle] boards` in the backend config; defaults to boards with a TCP socket). `TCP_NODELAY` is set, so one segment per keep-alive, plus an optional pure-ACK segment each (worst case). Note the real backend treats an interval ≤ 0 as 50 ms, while the UI treats 0 as "off". +- **Bytes per frame** depend on `TrafficOptions.countAs` (`CountAs`, the "Count bytes as" switch): + - `"wire"` (the default): line time. It is payload + transport header (UDP 8 / TCP 20, no options) + IPv4 20, padded to the 46 B minimum Ethernet payload, + Ethernet 14 + FCS 4 + preamble 8 + inter-frame gap 12. Every packet with ≤ 18 B of UDP payload therefore costs 84 B. + - `"wireshark"`: the frame length a capture on the backend host shows. It has no preamble, gap or FCS (NICs don't deliver them). Frames the backend receives ("up") are padded, so a small one is 60 B. Frames it sends ("down") are captured before the NIC pads them: a keep-alive is 56 B and a pure ACK 54 B. `wireBreakdown` takes the direction for this reason, and the chosen mode travels in `WireBreakdown.countAs`, so the UI can label steps without extra props. + - Neither mode models fragmentation, VLAN tags, IP/TCP options or retransmissions. +- `TrafficOptions` also toggles UDP, keep-alive and separate ACKs; all on is the worst case and the default. Change byte sizes only if the backend codec changes. + +**What-if periods.** Clicking a UDP packet's period opens a value + unit editor (Enter/blur applies, Escape cancels). `PeriodOverrides` maps packet id → `{ value, unit }`; an override equal to the ADJ period in seconds (e.g. 10000 us vs 10 ms) is dropped. Overridden flows carry the original in `adjPeriod`/`adjPeriodUnit`; the ADJ data is never mutated. + +**UI.** A sticky "Scenario" panel on the left (link capacity with 1M/10M/100M/1G presets, per-direction vs both-ways view, on the wire vs Wireshark counting, the two keep-alive intervals, the `TrafficOptions` checkboxes) and results on the right: one stacked bar per direction where 100% = link capacity, each board a segment (solid = UDP, 45°-striped = keep-alive), then a board table that doubles as the legend and expands into per-flow tables and a 3-step calculation breakdown. Board colours come from `--series-1…8` / `--series-other` in `src/index.css` (a CVD-validated categorical palette), assigned by fixed alphabetical board index — never by rank, so toggling things never repaints other boards. + +**Help.** `ThroughputHelp.tsx` is the "How the numbers are calculated" side sheet. Every figure in it (layer sizes, minimums/maximums, worked example, keep-alive rate) is computed from the constants exported by `throughput.ts`; add new model assumptions there, never as literals in the help text. + +### Workspace dependency + +Only shared package used is `@workspace/ui` (from `frontend-kit/ui`), for shadcn/Radix components, Lucide icons, and small utilities (`cn`, `getTypeBadgeClass`/`typeBadgeClasses` from `@workspace/ui/lib`): + +```tsx +import { Button, Combobox } from "@workspace/ui/components"; +import { BookOpen, GitCommit } from "@workspace/ui/icons"; +import { cn, getTypeBadgeClass, typeBadgeClasses } from "@workspace/ui/lib"; +``` + +No `@workspace/core` dependency (no WebSocket/backend integration here). + +### Styling + +Tailwind v4 with CSS-variable theming, dark mode via `.dark` class on `` (toggled in `App.tsx`, persisted to `localStorage["adj-view-dark-mode"]`). `NetworkTab`'s SVG reads the same CSS variables (`var(--primary)`, `var(--foreground)`, etc.) directly in inline styles so the diagram adapts automatically between themes. UI copy is English (sentence case, no all-caps labels); numbers use `tabular-nums` rather than a monospace font. + +Branding: the header and empty state use the team mark `@workspace/ui/outreach/main/logo_icon.svg` (not the H11 isotype the other views use), via `TeamLogo` in `AdjViewerPage.tsx`. The header pairs it with the software-team logo (`outreach/main/software_black.png` + `dark:invert`; don't use `software_white.png`, which is 8000 px / 430 KB). That SVG draws the mark in only the middle ~49% of its 900×900 viewBox, so `TeamLogo` scales the image up inside a clipped box; a plain `` renders it tiny. + +The layout must work down to phone width (check 390 / 768 / 1440 px) without the page scrolling sideways. The header wraps into title + theme toggle, then a full-width row of load controls below `lg`. The tab bar scrolls inside itself, and wide tables and the Network SVG scroll inside their own containers. + +### Gotchas + +- **Kit spacing tokens hijack named sizes**: `frontend-kit`'s `--spacing-sm/md/…` make `max-w-sm` etc. resolve to a few px (the kit's own `SheetContent` ships `sm:max-w-sm`). Always use arbitrary values like `max-w-[42rem]`. +- **SWC drops a leading space in multi-line JSX text**: in `Label: text that wraps onto\n more lines`, the space after `` disappears. Write `{" "}text`. +- **Number inputs**: native spinners ignore the theme and overlap right-aligned values, so the Throughput tab's number fields use `StepperInput` (hidden native spinner + themed ▲/▼ buttons, ±1, never below 0; arrow keys still work). Reuse it for new numeric fields. +- **Sticky needs no clipping ancestor**: the Throughput `TabsContent` deliberately has no `overflow-hidden` (unlike the other tabs); adding it back breaks the sticky Scenario panel. Page scrolling happens on `App.tsx`'s root `overflow-auto` div, since the tab content isn't height-constrained. diff --git a/frontend/adj-view/src/adj/AdjViewer.tsx b/frontend/adj-view/src/adj/AdjViewer.tsx new file mode 100644 index 000000000..3496ad00c --- /dev/null +++ b/frontend/adj-view/src/adj/AdjViewer.tsx @@ -0,0 +1,11 @@ +import { assertNever, type LoadedAdj } from "."; +import { AdjViewerTabs } from "./v2/AdjViewerTabs"; + +export function AdjViewer({ adj }: { adj: LoadedAdj }) { + switch (adj.version) { + case 2: + return ; + default: + return assertNever(adj.version); + } +} diff --git a/frontend/adj-view/src/adj/index.ts b/frontend/adj-view/src/adj/index.ts new file mode 100644 index 000000000..b11037199 --- /dev/null +++ b/frontend/adj-view/src/adj/index.ts @@ -0,0 +1,60 @@ +// Version dispatch for ADJ archives. Each ADJ format version lives in its own +// `vN/` folder; this module is the only place that knows which versions exist. +import { summarizeV2 } from "./v2/summary"; +import type { AdjArchiveV2 } from "./v2/types"; + +// Archives published before versioning was introduced carry no `version` key. +const DEFAULT_ADJ_VERSION = 2; + +// Keep in sync with the `case`s in parseAdj — shown to the user when an +// archive's version isn't one of these. +export const SUPPORTED_ADJ_VERSIONS: readonly number[] = [2]; + +export type LoadedAdj = { version: 2; data: AdjArchiveV2 }; + +// An archive with a well-formed but unknown version is a normal outcome (the +// ADJ format moved on before the viewer did), not a load error. +export type ParsedAdj = + | { supported: true; adj: LoadedAdj } + | { supported: false; version: number }; + +export interface AdjSummary { + boards: number; + measurements: number; + packets: number; +} + +// Compile-time exhaustiveness check: adding a member to LoadedAdj makes every +// switch that forgets to handle it fail to type-check here. +export function assertNever(version: never): never { + throw new Error(`Unhandled ADJ version: ${JSON.stringify(version)}`); +} + +export function detectAdjVersion(raw: unknown): number { + const version = (raw as { version?: unknown } | null)?.version; + if (version === undefined || version === null) return DEFAULT_ADJ_VERSION; + const parsed = typeof version === "number" ? version : Number(version); + if (typeof version === "boolean" || version === "" || !Number.isInteger(parsed)) { + throw new Error(`Invalid ADJ version: ${JSON.stringify(version)}`); + } + return parsed; +} + +export function parseAdj(raw: unknown): ParsedAdj { + const version = detectAdjVersion(raw); + switch (version) { + case 2: + return { supported: true, adj: { version: 2, data: raw as AdjArchiveV2 } }; + default: + return { supported: false, version }; + } +} + +export function summarizeAdj(adj: LoadedAdj): AdjSummary { + switch (adj.version) { + case 2: + return summarizeV2(adj.data); + default: + return assertNever(adj.version); + } +} diff --git a/frontend/adj-view/src/components/AdjViewerTabs.tsx b/frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx similarity index 86% rename from frontend/adj-view/src/components/AdjViewerTabs.tsx rename to frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx index 4987b78d9..0ac811b90 100644 --- a/frontend/adj-view/src/components/AdjViewerTabs.tsx +++ b/frontend/adj-view/src/adj/v2/AdjViewerTabs.tsx @@ -1,9 +1,8 @@ -// Tab-based ADJ archive browser (Boards / Measurements / Packets / General). +// Tab-based ADJ archive browser (Boards / Measurements / Packets / Network / Sockets / Throughput / General). // Pure data-in component — the page hosting it owns commit-hash fetching, // loading/error states, and header chrome. import { Badge, - Input, Tabs, TabsContent, TabsList, @@ -13,18 +12,22 @@ import { Activity, ChevronDown, ChevronRight, - ChevronUp, Cpu, ExternalLink, Layers, Network, - Search, + Plug, Server, + TrendingUp, } from "@workspace/ui/icons"; import { cn, getTypeBadgeClass, typeBadgeClasses } from "@workspace/ui/lib"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import type { AdjArchive, AdjMeasurement, AdjPacket, AdjSocket } from "../types/adj"; +import { Fragment, useCallback, useMemo, useRef, useState } from "react"; +import type { AdjArchiveV2, AdjMeasurement, AdjPacket, AdjSocket } from "./types"; import { NetworkTab } from "./NetworkTab"; +import { SocketsTab } from "./SocketsTab"; +import { ThroughputTab } from "./ThroughputTab"; +import { BoardChip, EmptyState, Highlight, ResultCount, SearchInput, SortableHeader, type SortDir } from "./ui"; +import { useKeyboardSearch } from "./useKeyboardSearch"; // ─── types ─────────────────────────────────────────────────────────────────── @@ -39,11 +42,10 @@ export type BoardMeta = { }; type SortKey = "board" | "name" | "type" | "units" | "id"; -type SortDir = "asc" | "desc"; // ─── data helpers ───────────────────────────────────────────────────────────── -export function extractBoards(adjData: AdjArchive): BoardMeta[] { +export function extractBoards(adjData: AdjArchiveV2): BoardMeta[] { return Object.entries(adjData.boards) .map(([boardName, boardGroup]) => { const g = boardGroup as Record; @@ -85,98 +87,6 @@ function exportCSV(rows: { board: string; name: string; type?: string; displayUn // ─── atom components ───────────────────────────────────────────────────────── -function Highlight({ text, query }: { text: string; query: string }) { - if (!query) return <>{text}; - const idx = text.toLowerCase().indexOf(query.toLowerCase()); - if (idx === -1) return <>{text}; - return ( - <> - {text.slice(0, idx)} - - {text.slice(idx, idx + query.length)} - - {text.slice(idx + query.length)} - - ); -} - -function ResultCount({ n, total }: { n: number; total: number }) { - return ( - - {n === total ? total : `${n} / ${total}`} - - ); -} - -function SearchInput({ - value, - onChange, - placeholder, - inputRef, -}: { - value: string; - onChange: (v: string) => void; - placeholder: string; - inputRef?: React.RefObject; -}) { - return ( -
- - onChange(e.target.value)} - className="h-8 pl-8 pr-8 text-xs shadow-none focus-visible:ring-0" - /> - {value && ( - - )} -
- ); -} - -function SortableHeader({ - label, - col, - sortKey, - sortDir, - onSort, -}: { - label: string; - col: SortKey; - sortKey: SortKey; - sortDir: SortDir; - onSort: (col: SortKey) => void; -}) { - const active = sortKey === col; - return ( - - - - ); -} - function TypeChip({ type, active, @@ -203,31 +113,6 @@ function TypeChip({ ); } -function BoardChip({ - name, - active, - onClick, -}: { - name: string; - active: boolean; - onClick: () => void; -}) { - return ( - - ); -} - function FilterPills({ activeBoards, activeTypes, @@ -279,15 +164,6 @@ function CopyValue({ value }: { value: string }) { ); } -function EmptyState({ text }: { text: string }) { - return ( -
- - {text} -
- ); -} - // ─── Boards tab ─────────────────────────────────────────────────────────────── function BoardsTab({ @@ -335,7 +211,7 @@ function BoardsTab({ {/* Stats row */}
-
+
{board.measurements.length} measurements {board.packets.length} packets {board.orders.length} orders @@ -519,9 +395,8 @@ function MeasurementsTab({ const expanded = expandedId === rowKey; const isEnum = r.type === "enum"; return ( - <> + setExpandedId(expanded ? null : rowKey)} className={cn( "border-b transition-colors", @@ -553,7 +428,7 @@ function MeasurementsTab({ {expanded && ( - +
{r.podUnits && ( @@ -577,7 +452,7 @@ function MeasurementsTab({ )} - + ); })} @@ -748,7 +623,7 @@ function GeneralSection({ ); } -function GeneralTab({ adjData }: { adjData: AdjArchive }) { +function GeneralTab({ adjData }: { adjData: AdjArchiveV2 }) { const [query, setQuery] = useState(""); const inputRef = useRef(null); useKeyboardSearch(inputRef); @@ -771,24 +646,9 @@ function GeneralTab({ adjData }: { adjData: AdjArchive }) { ); } -// ─── hook: / key focuses the nearest search input ───────────────────────────── - -function useKeyboardSearch(ref: React.RefObject) { - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") { - e.preventDefault(); - ref.current?.focus(); - } - }; - document.addEventListener("keydown", handler); - return () => document.removeEventListener("keydown", handler); - }, [ref]); -} - // ─── main tabs component ───────────────────────────────────────────────────── -export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { +export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchiveV2 }) => { const boards = useMemo(() => extractBoards(adjData), [adjData]); // Lifted state for cross-tab navigation @@ -815,21 +675,28 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { ); return ( - - - + + {/* On narrow screens the bar scrolls sideways by itself instead of widening the page. */} + + Boards - + Measurements - + Packets - + Network - + + Sockets + + + Throughput + + General @@ -853,6 +720,18 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { + + + + {/* 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/components/NetworkTab.tsx b/frontend/adj-view/src/adj/v2/NetworkTab.tsx similarity index 83% rename from frontend/adj-view/src/components/NetworkTab.tsx rename to frontend/adj-view/src/adj/v2/NetworkTab.tsx index c2b21dc66..63de5afb7 100644 --- a/frontend/adj-view/src/components/NetworkTab.tsx +++ b/frontend/adj-view/src/adj/v2/NetworkTab.tsx @@ -4,10 +4,9 @@ // monorepo, and the node/edge count here is small enough that manual two-column // layout is simpler than pulling one in. import { useMemo } from "react"; -import type { AdjArchive } from "../types/adj"; +import type { AdjArchiveV2 } from "./types"; import type { BoardMeta } from "./AdjViewerTabs"; - -type Protocol = "TCP" | "UDP" | "OTHER"; +import { boardPort, protocolFromSocketType, resolveTarget, type Protocol } from "./sockets"; // Hyperloop UPV's actual brand palette (no separate secondary brand color // documented anywhere in the repo) — --primary is the brand orange, and @@ -19,16 +18,6 @@ const PROTOCOL_COLOR: Record = { OTHER: "var(--muted-foreground)", }; -// Socket "type" comes straight from the ADJ archive (Java-style class names: -// ServerSocket = TCP, DatagramSocket = UDP) — derive protocol from it rather -// than hardcoding specific socket names. -function protocolFromSocketType(type: string): Protocol { - const t = type.toLowerCase(); - if (t.includes("datagram")) return "UDP"; - if (t.includes("server") || t.includes("stream") || t.includes("tcp")) return "TCP"; - return "OTHER"; -} - // ─── graph model ─────────────────────────────────────────────────────────── interface DiagramNode { @@ -53,20 +42,7 @@ interface NetworkGraph { edges: DiagramEdge[]; } -// A socket's remote_ip may be a symbolic key into `addresses` (e.g. "backend") -// or the raw IP itself — resolve either form to a stable node id + label. -function resolveTarget(remoteIp: string, addresses: Record) { - if (remoteIp in addresses) { - return { id: remoteIp, label: remoteIp, ip: addresses[remoteIp] }; - } - const knownKey = Object.entries(addresses).find(([, ip]) => ip === remoteIp)?.[0]; - if (knownKey) { - return { id: knownKey, label: knownKey, ip: remoteIp }; - } - return { id: `ip:${remoteIp}`, label: remoteIp, ip: remoteIp }; -} - -function buildNetworkGraph(boards: BoardMeta[], generalInfo: AdjArchive["general_info"]): NetworkGraph { +function buildNetworkGraph(boards: BoardMeta[], generalInfo: AdjArchiveV2["general_info"]): NetworkGraph { const addresses = generalInfo.addresses ?? {}; const centralMap = new Map(); for (const [key, ip] of Object.entries(addresses)) { @@ -74,10 +50,10 @@ function buildNetworkGraph(boards: BoardMeta[], generalInfo: AdjArchive["general } // A socket's remote_ip can also point at another board directly (board-to-board - // traffic) — route those to the existing board node instead of resolveTarget's - // "unknown external IP" fallback, which would otherwise draw a second, duplicate - // node for an IP that's already shown on the left as a board. - const boardIdByIp = new Map(boards.map((b) => [b.ip, b.name])); + // traffic) — resolveTarget routes those to the existing board node instead of + // the "unknown external IP" fallback, which would otherwise draw a second, + // duplicate node for an IP that's already shown on the left as a board. + const boardByIp = new Map(boards.map((b) => [b.ip, b.name])); // Listen-only sockets (no remote_ip) have no recorded source — infer the // backend as the source only when there's an unambiguous one to attribute it to. @@ -90,24 +66,18 @@ function buildNetworkGraph(boards: BoardMeta[], generalInfo: AdjArchive["general const badges: string[] = []; for (const socket of board.sockets) { const protocol = protocolFromSocketType(socket.type); + const port = boardPort(socket); if (socket.remote_ip) { - const targetBoardName = boardIdByIp.get(socket.remote_ip); - let to: string; - if (targetBoardName) { - to = targetBoardName; - } else { - const target = resolveTarget(socket.remote_ip, addresses); - if (!centralMap.has(target.id)) { - centralMap.set(target.id, { id: target.id, label: target.label, ip: target.ip, badges: [] }); - } - to = target.id; + const target = resolveTarget(socket.remote_ip, addresses, boardByIp); + if (target.kind !== "board" && !centralMap.has(target.id)) { + centralMap.set(target.id, { id: target.id, label: target.label, ip: target.ip, badges: [] }); } edges.push({ key: `${board.name}-${socket.name}-out`, from: board.name, - to, + to: target.id, protocol, - detail: `${socket.name} · :${socket.port}`, + detail: `${socket.name} · :${port ?? "?"}`, }); } else if (backendKey) { edges.push({ @@ -115,10 +85,10 @@ function buildNetworkGraph(boards: BoardMeta[], generalInfo: AdjArchive["general from: backendKey, to: board.name, protocol, - detail: `${socket.name} · :${socket.port}`, + detail: `${socket.name} · :${port ?? "?"}`, }); } else { - badges.push(`listens :${socket.port}`); + badges.push(`listens :${port ?? "?"}`); } } boardNodes.push({ id: board.name, label: board.name, ip: board.ip, boardId: board.id, badges }); @@ -233,7 +203,7 @@ function Legend() { ); } -export function NetworkTab({ boards, generalInfo }: { boards: BoardMeta[]; generalInfo: AdjArchive["general_info"] }) { +export function NetworkTab({ boards, generalInfo }: { boards: BoardMeta[]; generalInfo: AdjArchiveV2["general_info"] }) { const graph = useMemo(() => buildNetworkGraph(boards, generalInfo), [boards, generalInfo]); const totalRows = Math.max(graph.boardNodes.length, graph.centralNodes.length, 1); diff --git a/frontend/adj-view/src/adj/v2/SocketsTab.tsx b/frontend/adj-view/src/adj/v2/SocketsTab.tsx new file mode 100644 index 000000000..7df69831d --- /dev/null +++ b/frontend/adj-view/src/adj/v2/SocketsTab.tsx @@ -0,0 +1,453 @@ +// Sockets tab: every socket declared in the ADJ, one row each, with the board +// side, where it points, the general_info port name, and the packets that use it. +// All socket interpretation comes from sockets.ts, shared with the Network tab. +import { Badge } from "@workspace/ui/components"; +import { AlertTriangle, ChevronDown, ExternalLink } from "@workspace/ui/icons"; +import { cn } from "@workspace/ui/lib"; +import { Fragment, useMemo, useRef, useState } from "react"; +import type { BoardMeta } from "./AdjViewerTabs"; +import { + boardPort, + portNames, + resolveTarget, + socketRole, + type SocketRole, + type SocketTarget, +} from "./sockets"; +import type { AdjArchiveV2, AdjPacket, AdjSocket } from "./types"; +import { BoardChip, EmptyState, Highlight, ResultCount, SearchInput, SortableHeader, type SortDir } from "./ui"; +import { useKeyboardSearch } from "./useKeyboardSearch"; + +type SortKey = "board" | "name" | "type" | "local" | "remote" | "packets"; + +const ROLES: SocketRole[] = ["TCP server", "TCP client", "UDP", "Unknown"]; + +// Same protocol colours as the Network tab's arrows, so the two tabs read alike. +const ROLE_DOT: Record = { + "TCP server": "var(--foreground)", + "TCP client": "var(--foreground)", + UDP: "var(--primary)", + Unknown: "var(--muted-foreground)", +}; + +type SocketUser = AdjPacket & { kind: "packet" | "order" }; + +interface SocketRow { + key: string; + board: string; + boardIp: string; + socket: AdjSocket; + role: SocketRole; + localPort?: number; + target?: SocketTarget; + portNames: string[]; + users: SocketUser[]; + duplicate: boolean; +} + +interface UnknownReference { + board: string; + socket: string; + users: SocketUser[]; +} + +function buildRows(boards: BoardMeta[], generalInfo: AdjArchiveV2["general_info"]) { + const addresses = generalInfo.addresses ?? {}; + const ports = generalInfo.ports ?? {}; + const boardByIp = new Map(boards.map((b) => [b.ip, b.name])); + const rows: SocketRow[] = []; + const unknown: UnknownReference[] = []; + + for (const board of boards) { + const users: SocketUser[] = [ + ...board.packets.map((p) => ({ ...p, kind: "packet" as const })), + ...board.orders.map((p) => ({ ...p, kind: "order" as const })), + ]; + const nameCount = new Map(); + for (const s of board.sockets) nameCount.set(s.name, (nameCount.get(s.name) ?? 0) + 1); + + board.sockets.forEach((socket, i) => { + const localPort = boardPort(socket); + rows.push({ + key: `${board.name}/${socket.name}/${i}`, + board: board.name, + boardIp: board.ip, + socket, + role: socketRole(socket.type), + localPort, + target: socket.remote_ip ? resolveTarget(socket.remote_ip, addresses, boardByIp) : undefined, + portNames: [...new Set([...portNames(localPort, ports), ...portNames(socket.remote_port, ports)])], + users: users.filter((u) => u.socket === socket.name), + duplicate: (nameCount.get(socket.name) ?? 0) > 1, + }); + }); + + const byMissing = new Map(); + for (const u of users) { + if (u.socket && !nameCount.has(u.socket)) byMissing.set(u.socket, [...(byMissing.get(u.socket) ?? []), u]); + } + for (const [socket, list] of byMissing) unknown.push({ board: board.name, socket, users: list }); + } + return { rows, unknown }; +} + +function remoteText(r: SocketRow): string { + if (!r.target) return r.role === "TCP server" ? "any client" : ""; + const port = r.socket.remote_port != null ? `:${r.socket.remote_port}` : ""; + return r.target.label === r.target.ip ? `${r.target.ip}${port}` : `${r.target.label} ${r.target.ip}${port}`; +} + +function csvCell(v: string | number | undefined) { + return `"${String(v ?? "").replace(/"/g, '""')}"`; +} + +function exportSocketsCSV(rows: SocketRow[]) { + const header = ["Board", "Socket", "Type", "Role", "Board IP", "Board port", "Remote", "Remote IP", "Remote port", "Port names", "Packets"]; + const lines = rows.map((r) => + [ + r.board, + r.socket.name, + r.socket.type, + r.role, + r.boardIp, + r.localPort, + r.target?.label, + r.target?.ip, + r.socket.remote_port, + r.portNames.join(" "), + r.users.map((u) => u.name).join("; "), + ] + .map(csvCell) + .join(","), + ); + const blob = new Blob([[header.join(","), ...lines].join("\n")], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "sockets.csv"; + a.click(); + URL.revokeObjectURL(url); +} + +// ─── pieces ────────────────────────────────────────────────────────────────── + +function RoleChip({ + role, + count, + active, + onClick, +}: { + role: SocketRole; + count: number; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +function RoleBadge({ role, type }: { role: SocketRole; type: string }) { + return ( + + + {role} + + ); +} + +function Warning({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + ); +} + +function UserList({ + board, + users, + onJumpToMeasurements, +}: { + board: string; + users: SocketUser[]; + onJumpToMeasurements: (boardName: string, packetName: string, variableIds: string[]) => void; +}) { + return ( +
    + {users.map((u) => { + const hasVariables = u.variables && u.variables.length > 0; + return ( +
  • + +
  • + ); + })} +
+ ); +} + +// ─── tab ───────────────────────────────────────────────────────────────────── + +export function SocketsTab({ + boards, + generalInfo, + onJumpToMeasurements, +}: { + boards: BoardMeta[]; + generalInfo: AdjArchiveV2["general_info"]; + onJumpToMeasurements: (boardName: string, packetName: string, variableIds: string[]) => void; +}) { + const [query, setQuery] = useState(""); + const [activeBoards, setActiveBoards] = useState>(new Set()); + const [activeRoles, setActiveRoles] = useState>(new Set()); + const [sortKey, setSortKey] = useState("board"); + const [sortDir, setSortDir] = useState("asc"); + const [expanded, setExpanded] = useState(null); + const inputRef = useRef(null); + useKeyboardSearch(inputRef); + + const { rows, unknown } = useMemo(() => buildRows(boards, generalInfo), [boards, generalInfo]); + + const roleCounts = useMemo(() => { + const counts = new Map(); + for (const r of rows) counts.set(r.role, (counts.get(r.role) ?? 0) + 1); + return counts; + }, [rows]); + + const q = query.trim().toLowerCase(); + + const filtered = useMemo(() => { + const sortValue = (r: SocketRow): string | number => { + switch (sortKey) { + case "board": return r.board; + case "name": return r.socket.name; + case "type": return r.role; + case "local": return r.localPort ?? Infinity; + case "remote": return remoteText(r); + case "packets": return r.users.length; + } + }; + return rows + .filter((r) => { + if (activeBoards.size > 0 && !activeBoards.has(r.board)) return false; + if (activeRoles.size > 0 && !activeRoles.has(r.role)) return false; + if (!q) return true; + const haystack = [r.board, r.socket.name, r.socket.type, r.role, r.boardIp, r.localPort, remoteText(r), r.socket.remote_port, ...r.portNames] + .join(" ") + .toLowerCase(); + return haystack.includes(q); + }) + .sort((a, b) => { + const dir = sortDir === "asc" ? 1 : -1; + const va = sortValue(a); + const vb = sortValue(b); + const cmp = typeof va === "number" && typeof vb === "number" ? va - vb : String(va).localeCompare(String(vb)); + // Ties keep ADJ order within a board. + return cmp * dir || a.board.localeCompare(b.board) || rows.indexOf(a) - rows.indexOf(b); + }); + }, [rows, activeBoards, activeRoles, q, sortKey, sortDir]); + + const toggleBoard = (name: string) => + setActiveBoards((s) => { const n = new Set(s); if (n.has(name)) n.delete(name); else n.add(name); return n; }); + const toggleRole = (role: SocketRole) => + setActiveRoles((s) => { const n = new Set(s); if (n.has(role)) n.delete(role); else n.add(role); return n; }); + const handleSort = (col: SortKey) => { + if (sortKey === col) setSortDir((d) => (d === "asc" ? "desc" : "asc")); + else { setSortKey(col); setSortDir("asc"); } + }; + + const hasFilters = activeBoards.size > 0 || activeRoles.size > 0; + const visibleUnknown = unknown.filter((u) => activeBoards.size === 0 || activeBoards.has(u.board)); + + return ( +
+
+ + + +
+ +
+ {boards.map((b) => ( + toggleBoard(b.name)} /> + ))} + + {ROLES.filter((role) => (roleCounts.get(role) ?? 0) > 0).map((role) => ( + toggleRole(role)} /> + ))} + {hasFilters && ( + + )} +
+ +
+ + + + + + + + + + + + + + {filtered.map((r) => { + const isOpen = expanded === r.key; + const canExpand = r.users.length > 0; + return ( + + setExpanded(isOpen ? null : r.key) : undefined} + className={cn( + "border-b align-top transition-colors", + canExpand ? "hover:bg-muted/30 cursor-pointer" : "hover:bg-muted/20", + isOpen && "bg-muted/20", + )} + > + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + +
+ Port name +
+ + +
+ {r.duplicate && Name used twice on this board} +
+ + + + {r.localPort != null ? ( + <>: + ) : ( + :? + )} + + {r.target ? ( +
+ {r.target.label !== r.target.ip && ( + + )} + + + {r.socket.remote_port != null && `:${r.socket.remote_port}`} + + {r.target.kind === "ip" && Not a known address or board} +
+ ) : r.role === "TCP server" ? ( + + Any client + + ) : ( + No remote IP + )} +
+ {r.portNames.length > 0 ? ( + + ) : ( + — + )} + + {canExpand ? ( + + {r.users.length} + + + ) : ( + 0 + )} +
+

+ Packets and orders of {r.board} that name {r.socket.name}. Click one to see its measurements. +

+ +
+ {filtered.length === 0 && } + + {visibleUnknown.length > 0 && ( +
+

+ + Unknown socket references +

+

+ These packets name a socket that their board doesn't define. +

+
+ {visibleUnknown.map((u) => ( +
+

+ {u.board} → {u.socket} +

+ +
+ ))} +
+
+ )} +
+
+ ); +} diff --git a/frontend/adj-view/src/adj/v2/ThroughputHelp.tsx b/frontend/adj-view/src/adj/v2/ThroughputHelp.tsx new file mode 100644 index 000000000..7f652257f --- /dev/null +++ b/frontend/adj-view/src/adj/v2/ThroughputHelp.tsx @@ -0,0 +1,323 @@ +// "How the numbers are calculated" side panel for the Throughput tab. Every +// figure is derived from the constants the calculation itself uses, so the +// help can't drift from the model. +import { + Button, + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@workspace/ui/components"; +import { BookOpen } from "@workspace/ui/icons"; +import type { ReactNode } from "react"; +import { + ETH_FCS_BYTES, + ETH_HEADER_BYTES, + ETH_INTERFRAME_GAP_BYTES, + ETH_MIN_PAYLOAD_BYTES, + ETH_MTU_BYTES, + ETH_PREAMBLE_SFD_BYTES, + formatBitrate, + IPV4_HEADER_BYTES, + PACKET_ID_BYTES, + TRANSPORT_HEADER_BYTES, + wireBreakdown, + type Transport, +} from "./throughput"; + +const UDP = TRANSPORT_HEADER_BYTES.UDP; +const TCP = TRANSPORT_HEADER_BYTES.TCP; +const L1 = ETH_PREAMBLE_SFD_BYTES + ETH_INTERFRAME_GAP_BYTES; +const MIN_FRAME = ETH_HEADER_BYTES + ETH_MIN_PAYLOAD_BYTES + ETH_FCS_BYTES; +const MAX_FRAME = ETH_HEADER_BYTES + ETH_MTU_BYTES + ETH_FCS_BYTES; +const noPaddingFrom = (t: Transport) => ETH_MIN_PAYLOAD_BYTES - IPV4_HEADER_BYTES - TRANSPORT_HEADER_BYTES[t]; +const maxPayload = (t: Transport) => ETH_MTU_BYTES - IPV4_HEADER_BYTES - TRANSPORT_HEADER_BYTES[t]; + +function Section({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function Formula({ children }: { children: ReactNode }) { + return
{children}
; +} + +function Table({ head, rows }: { head: ReactNode[]; rows: ReactNode[][] }) { + return ( +
+ + + + {head.map((h, i) => ( + + ))} + + + + {rows.map((r, i) => ( + + {r.map((c, j) => ( + + ))} + + ))} + +
+ {h} +
+ {c} +
+
+ ); +} + +export function ThroughputHelp({ backendMs, boardMs }: { backendMs: number; boardMs: number }) { + // Worked example: one enum sent every 10 ms, like "VCU State". + const ex = wireBreakdown(PACKET_ID_BYTES + 1, "UDP", "wire", "up"); + const exShark = wireBreakdown(PACKET_ID_BYTES + 1, "UDP", "wireshark", "up"); + const exHz = 100; + const ka = wireBreakdown(PACKET_ID_BYTES, "TCP", "wire", "down"); + // What a capture on the backend shows: received frames padded, sent frames not. + const shark = { + udp: exShark.total, + kaIn: wireBreakdown(PACKET_ID_BYTES, "TCP", "wireshark", "up").total, + kaOut: wireBreakdown(PACKET_ID_BYTES, "TCP", "wireshark", "down").total, + ackIn: wireBreakdown(0, "TCP", "wireshark", "up").total, + ackOut: wireBreakdown(0, "TCP", "wireshark", "down").total, + }; + const wire = { + udp: ex.total, + ka: ka.total, + ack: wireBreakdown(0, "TCP", "wire", "up").total, + }; + const kaRate = (ms: number) => (ms > 0 ? 1000 / ms : 0); + const kaPerDirection = ka.total * 8 * (kaRate(backendMs) + kaRate(boardMs)); + + return ( + + + + + + + How the numbers are calculated + + Every figure in this tab follows from the ADJ, the backend's wire format and the standard sizes of UDP, + TCP, IPv4 and Ethernet. Nothing is measured on a real network. + + + +
+
+
    +
  • + UDP data packets:{" "}packets whose socket is one of the board's UDP + (DatagramSocket) sockets and that have a period. Orders and packets without a period send nothing on + their own, so they are left out. +
  • +
  • + TCP keep-alive:{" "}the backend sends an empty packet with ID 1 to every + connected board, and each board answers with the same packet (backend{" "} + pkg/transport/keepalive.go). +
  • +
  • + TCP ACKs:{" "}in the worst case every keep-alive is acknowledged by its + own segment. If the ACK rides on the other side's keep-alive instead, turn "Separate TCP ACKs" + off. +
  • +
+

+ "To backend" is board → backend traffic: UDP data, board keep-alives and the ACKs of the backend's + keep-alives. "From backend" is the backend's keep-alives and the ACKs of the boards' ones. +

+
+ +
+

+ Each UDP datagram carries exactly one packet: a {PACKET_ID_BYTES}-byte packet ID (uint16, little-endian) + followed by its variables in ADJ order, with no padding between them (backend{" "} + pkg/transport/packet/data/codec.go). +

+ + + UDP payload = {PACKET_ID_BYTES} B (ID) + Σ variable sizes +
+ Keep-alive payload = {PACKET_ID_BYTES} B (ID only) · Pure ACK payload = 0 B +
+ + +
+

Each layer adds its own header around the payload:

+
+ + IP packet = payload + transport header + {IPV4_HEADER_BYTES} B +
+ Padding = max(0, {ETH_MIN_PAYLOAD_BYTES} B − IP packet) +
+ Ethernet frame = {ETH_HEADER_BYTES} B + IP packet + padding + {ETH_FCS_BYTES} B +
+ On the wire = {ETH_PREAMBLE_SFD_BYTES} B + frame + {ETH_INTERFRAME_GAP_BYTES} B +
+

+ Ethernet can't send less than {ETH_MIN_PAYLOAD_BYTES} B inside a frame, so short IP packets are padded + with zeros. Preamble and gap aren't data, but the link is busy during them, so they count toward + bandwidth use. Switch "Count bytes as" to Wireshark to count frames the way a capture shows them + instead (see below). +

+ + +
+
+

+ Any UDP packet with up to {noPaddingFrom("UDP")} B of payload (ID + up to {noPaddingFrom("UDP") - PACKET_ID_BYTES}{" "} + B of variables) costs the same {MIN_FRAME + L1} B on the wire. Sending several tiny packets at the same + rate costs much more than one packet that merges them. +

+

+ A keep-alive ({PACKET_ID_BYTES} B) and a pure ACK (0 B) are both below the TCP minimum, so each costs{" "} + {MIN_FRAME + L1} B on the wire. Packets bigger than {maxPayload("UDP")} B of UDP payload would be split into + IP fragments; that isn't modelled. +

+ + +
+

+ Wireshark's frame length ("bytes on wire" in the frame details, and what its IO graphs and + Conversations add up) is not the same as the line time above. The network card never hands over the + preamble, SFD or inter-frame gap, and strips the FCS. Padding depends on who sent the frame: frames the + backend receives arrive already padded, but frames it sends are captured before its card pads them. +

+
+ + Wireshark frame = {ETH_HEADER_BYTES} B + IP packet + padding (received frames only) + +

+ The Wireshark mode assumes the capture runs on the backend host. A capture from a switch mirror port sees + every frame padded, so sent frames show at least {ETH_HEADER_BYTES + ETH_MIN_PAYLOAD_BYTES} B there too. + A card configured to keep the FCS would add {ETH_FCS_BYTES} B per frame. +

+ + +
+ + Rate (Hz) = 1 ÷ period in seconds (period units: ns, us, ms, s) +
+ Throughput (bit/s) = bytes per packet × 8 × rate +
+ Efficiency = payload ÷ bytes on the wire +
+

+ Both the payload rate and the counted rate are shown; the bars and board totals use the counted rate, + on the wire or as in Wireshark depending on "Count bytes as". Units are decimal: 1 kbit/s = 1,000 + bit/s, 1 Mbit/s = 1,000,000 bit/s. +

+
+ +
+
+ + Rate = 1 ÷ 0.01 s = {exHz} Hz +
+ Payload: {ex.payload} B × 8 × {exHz} Hz = {formatBitrate(ex.payload * 8 * exHz)} +
+ On the wire: {ex.total} B × 8 × {exHz} Hz = {formatBitrate(ex.total * 8 * exHz)} (efficiency{" "} + {((ex.payload / ex.total) * 100).toFixed(1)}%) +
+ In Wireshark: {exShark.total} B × 8 × {exHz} Hz = {formatBitrate(exShark.total * 8 * exHz)} +
+ + +
+

+ The backend sends the keep-alive every tcp.keep_alive_interval_ms (50 ms in{" "} + cmd/config.toml) to the boards listed in [vehicle] boards; "TCP + connected" stands in for that list. The connection uses TCP_NODELAY, so each keep-alive is its own + segment. +

+ + Per connected board and direction = {ka.total} B × 8 × (keep-alives + ACKs per second) +
+ With your intervals ({backendMs} ms / {boardMs} ms) and separate ACKs: {formatBitrate(kaPerDirection)} each + way +
+
+ +
+

+ 100% of a bar is the link capacity you set. Each board's segment is its traffic ÷ capacity; the solid + part is UDP and the striped part is keep-alive. Ethernet is full duplex, so each direction has the whole + capacity to itself: "Per direction" shows two bars, "Both ways" adds them into one. +

+
+ +
+
    +
  • IPv4 and TCP without options. TCP timestamps would add 12 B, a VLAN tag 4 B per frame.
  • +
  • Periods are exact; there is no jitter, loss or retransmission.
  • +
  • No IP fragmentation: every packet is assumed to fit in one frame.
  • +
  • Other traffic (ARP, SNTP, TFTP, orders sent by the operator) isn't included.
  • +
  • Edited periods only change this estimate, never the ADJ.
  • +
+
+ + + + ); +} diff --git a/frontend/adj-view/src/adj/v2/ThroughputTab.tsx b/frontend/adj-view/src/adj/v2/ThroughputTab.tsx new file mode 100644 index 000000000..a07766ae7 --- /dev/null +++ b/frontend/adj-view/src/adj/v2/ThroughputTab.tsx @@ -0,0 +1,1022 @@ +import { + Button, + Checkbox, + Input, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@workspace/ui/components"; +import { AlertTriangle, ChevronDown, ChevronRight, ChevronUp, Pencil } from "@workspace/ui/icons"; +import { cn } from "@workspace/ui/lib"; +import { Fragment, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; +import type { BoardMeta } from "./AdjViewerTabs"; +import { ThroughputHelp } from "./ThroughputHelp"; +import { + boardTotals, + computeBoardThroughput, + COUNTED_LABEL, + DEFAULT_TRAFFIC_OPTIONS, + ETH_HEADER_BYTES, + ETH_MIN_PAYLOAD_BYTES, + formatBitrate, + IPV4_HEADER_BYTES, + keepAliveFlows, + PACKET_ID_BYTES, + PERIOD_UNITS, + periodSeconds, + type BoardThroughput, + type PeriodOverride, + type BoardTotals, + type CountAs, + type Direction, + type TrafficFlow, + type TrafficOptions, +} from "./throughput"; + +// What-if periods by board then packet id. Only committed, valid values are +// stored; drafts live in the cell editor. +type PeriodOverridesByBoard = Record>; + +type OnPeriodChange = (f: TrafficFlow, override: PeriodOverride | null) => void; + +// Native number spinners ignore the theme and overlap right-aligned values in +// these narrow inputs, so StepperInput hides them and draws its own. +const NO_SPINNER = + "[appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"; + +// Steps by 1, never below 0; rounds away float noise (0.1 + 1 = 1.1, not 1.1000000000000001). +const stepValue = (value: string, delta: number) => String(+Math.max(0, (Number(value) || 0) + delta).toPrecision(12)); + +function StepperInput({ + value, + onValueChange, + className, + disabled, + ...props +}: Omit, "value" | "onChange" | "type"> & { + value: string; + onValueChange: (v: string) => void; +}) { + const arrow = + "text-muted-foreground hover:bg-muted hover:text-foreground flex h-1/2 w-full items-center justify-center transition-colors disabled:pointer-events-none"; + return ( +
+ onValueChange(e.target.value)} + className={cn("pr-6", NO_SPINNER, className)} + /> + {/* Not tab stops: the input's own arrow keys already step the value. */} +
+ + +
+
+ ); +} + +// ─── series colors ─────────────────────────────────────────────────────────── + +// Fixed slot per board (alphabetical order from extractBoards), never by rank, +// so toggling options or boards never repaints the survivors. Past the palette +// size, boards fold into a single "Other" segment. +const SERIES_SLOTS = 8; +const seriesColor = (index: number) => + index < SERIES_SLOTS ? `var(--series-${index + 1})` : "var(--series-other)"; + +// Keep-alive is drawn as a 45° texture of the board's own color, so the board +// keeps its identity and UDP vs keep-alive needs no extra hue. +const keepAliveFill = (color: string): CSSProperties => ({ + backgroundImage: `repeating-linear-gradient(45deg, ${color} 0 3px, color-mix(in oklab, ${color} 30%, transparent) 3px 6px)`, +}); + +const DIRECTION_LABEL: Record = { + up: "Board → backend", + down: "Backend → board", +}; + +const pctOf = (bps: number, capacity: number | null) => { + if (!capacity) return "—"; + const p = (bps / capacity) * 100; + if (p === 0) return "0%"; + return p < 0.01 ? "<0.01%" : `${p < 1 ? p.toFixed(2) : p.toFixed(1)}%`; +}; + +// ─── calculation breakdown ─────────────────────────────────────────────────── + +function Step({ label, value, note, total }: { label: ReactNode; value: ReactNode; note?: ReactNode; total?: boolean }) { + return ( +
+ + {label} + {note && {note}} + + {value} +
+ ); +} + +// The three steps are a real sequence (payload → frame → rate), hence the numbers. +function Section({ n, title, children }: { n: number; title: string; children: ReactNode }) { + return ( +
+

+ {n} + {title} +

+ {children} +
+ ); +} + +function FlowBreakdown({ f }: { f: TrafficFlow }) { + const { wire } = f; + const rate = `${f.rateHz.toFixed(2)} Hz`; + const efficiency = (f.payloadBytes / f.wireBytes) * 100; + const wireshark = wire.countAs === "wireshark"; + const excluded = wireshark ? "not in Wireshark" : "not counted"; + const counted = COUNTED_LABEL[wire.countAs]; + const paddingNote = + wireshark && f.direction === "down" + ? "added by the NIC after capture" + : wire.padding > 0 + ? `up to the ${ETH_MIN_PAYLOAD_BYTES} B minimum` + : `already ${ETH_MIN_PAYLOAD_BYTES} B or more`; + + return ( +
+
+ {f.hasPacketId ? ( + <> +
+ + {f.fields.map((field) => ( + + ))} +
+ 0 + ? `Payload, ID + ${f.fields.length} variable${f.fields.length === 1 ? "" : "s"}` + : "Payload, ID only" + } + value={`${f.payloadBytes} B`} + /> + + ) : ( + + )} +
+ +
+ + + + + + + + + + + +
+ +
+ + + +
+ {f.payloadBytes} B × 8 bit × {rate} +
+ +
+ {f.wireBytes} B × 8 bit × {rate} +
+ +
+
+ ); +} + +// ─── flow table ────────────────────────────────────────────────────────────── + +// Shows the period as text; a click opens a value + unit editor. Enter or +// leaving the editor applies a valid draft, Escape discards it. +function PeriodCell({ f, onChange }: { f: TrafficFlow; onChange: OnPeriodChange }) { + const [draft, setDraft] = useState<{ value: string; unit: string } | null>(null); + // Unmounting the focused editor fires a blur; this stops it from applying a + // draft that Enter already applied or Escape discarded. + const closing = useRef(false); + + const modified = f.adjPeriod !== undefined; + const adjLabel = modified ? `${f.adjPeriod} ${f.adjPeriodUnit}` : `${f.period} ${f.periodUnit}`; + const valid = draft !== null && draft.value.trim() !== "" && Number(draft.value) > 0; + + const open = () => { + closing.current = false; + setDraft({ value: String(f.period), unit: f.periodUnit }); + }; + const close = (apply: boolean) => { + if (closing.current) return; + closing.current = true; + if (apply && valid) onChange(f, { value: Number(draft.value), unit: draft.unit }); + setDraft(null); + }; + + if (draft) { + return ( +
+ ); + } + + return ( + + ); +} + +function FlowTable({ + title, + flows, + onPeriodChange, +}: { + title: string; + flows: TrafficFlow[]; + onPeriodChange?: OnPeriodChange; +}) { + const [open, setOpen] = useState(null); + const directions = (["up", "down"] as const).filter((d) => flows.some((f) => f.direction === d)); + + return ( +
+
+

{title}

+ + {flows.length} {flows.length === 1 ? "flow" : "flows"}, click one to see its calculation + +
+
e.stopPropagation()}> +
{ + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) close(true); + }} + onKeyDown={(e) => { + if (e.key === "Enter" && valid) close(true); + if (e.key === "Escape") close(false); + }} + > + e.target.select()} + onValueChange={(v) => setDraft({ ...draft, value: v })} + className="h-7 w-[5rem] pl-2 text-right text-xs tabular-nums" + /> + +
+
e.stopPropagation()}> +
+ {modified && ( + + )} + +
+
+ + + + + + + + + + + + + {flows.map((f) => { + const isOpen = open === f.key; + return ( + + setOpen(isOpen ? null : f.key)} + className={cn("hover:bg-muted/40 cursor-pointer border-b last:border-0", isOpen && "bg-muted/40")} + > + + + {onPeriodChange ? ( + + ) : ( + + )} + + + + + + {isOpen && ( + + + + )} + + ); + })} + + + {directions.map((d, i) => ( + + + + + ))} + +
FlowDirectionPeriodRatePayload{COUNTED_LABEL[flows[0]?.wire.countAs ?? "wire"]}Throughput
+ + + {f.name} + {f.id != null && #{f.id}} + + {DIRECTION_LABEL[f.direction]} + {f.period} {f.periodUnit} + {f.rateHz.toFixed(2)} Hz{f.payloadBytes} B{f.wireBytes} B{formatBitrate(f.wireBps)}
+ +
+ Total, {DIRECTION_LABEL[d].toLowerCase()} + + {formatBitrate(flows.filter((f) => f.direction === d).reduce((s, f) => s + f.wireBps, 0))} +
+
+ ); +} + +// ─── stacked capacity bar ──────────────────────────────────────────────────── + +interface Segment { + key: string; + label: string; + color: string; + udp: number; + keepAlive: number; +} + +function StackedBar({ title, segments, capacityBps }: { title: string; segments: Segment[]; capacityBps: number }) { + const total = segments.reduce((s, x) => s + x.udp + x.keepAlive, 0); + const over = total > capacityBps; + // 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); + + return ( +
+
+ {title} + {formatBitrate(total)} + + of {formatBitrate(capacityBps).replace(/\.00 /, " ")} + +
+
+ {visible.map((s) => { + const bps = s.udp + s.keepAlive; + return ( + + +
+ {s.udp > 0 &&
} + {s.keepAlive > 0 &&
} +
+ + +
{s.label}
+ {s.udp > 0 &&
UDP {formatBitrate(s.udp)}
} + {s.keepAlive > 0 &&
Keep-alive {formatBitrate(s.keepAlive)}
} +
+ {formatBitrate(bps)}, {pctOf(bps, capacityBps)} of the link +
+
+ + ); + })} + {!over &&
} +
+
+ {over ? ( + + + Exceeds the link by {formatBitrate(total - capacityBps)} + + ) : ( + <> + {pctOf(total, capacityBps)} used + {formatBitrate(capacityBps - total)} free + + )} +
+
+ ); +} + +// ─── scenario panel ────────────────────────────────────────────────────────── + +function Group({ title, children }: { title: string; children: ReactNode }) { + return ( +
+

{title}

+ {children} +
+ ); +} + +function NumberField({ + label, + unit, + value, + onChange, + disabled, +}: { + label: string; + unit: string; + value: string; + onChange: (v: string) => void; + disabled?: boolean; +}) { + const invalid = !(Number(value) > 0); + return ( + + ); +} + +function Option({ + label, + hint, + checked, + onChange, +}: { + label: string; + hint: string; + checked: boolean; + onChange: (v: boolean) => void; +}) { + return ( + + ); +} + +const CAPACITY_PRESETS = ["1", "10", "100", "1000"]; + +type View = "direction" | "combined"; + +function Segmented({ + value, + onChange, + options, +}: { + value: T; + onChange: (v: T) => void; + options: [T, string][]; +}) { + return ( +
+ {options.map(([v, label]) => ( + + ))} +
+ ); +} + +// ─── tab ───────────────────────────────────────────────────────────────────── + +export function ThroughputTab({ boards }: { boards: BoardMeta[] }) { + const [options, setOptions] = useState(DEFAULT_TRAFFIC_OPTIONS); + const [view, setView] = useState("direction"); + const [capacityMbps, setCapacityMbps] = useState("100"); + const [backendMs, setBackendMs] = useState("50"); + const [boardMs, setBoardMs] = useState("50"); + const [expanded, setExpanded] = useState(null); + const [periodOverrides, setPeriodOverrides] = useState({}); + + const rows = useMemo( + () => + boards.map((b) => + computeBoardThroughput( + b, + options.countAs, + new Map(Object.entries(periodOverrides[b.name] ?? {}).map(([id, o]) => [Number(id), o])), + ), + ), + [boards, options.countAs, periodOverrides], + ); + const [connected, setConnected] = useState>(() => new Set(rows.filter((r) => r.hasTcp).map((r) => r.board))); + + const kaFlows = useMemo( + () => + options.keepAlive + ? keepAliveFlows({ backendMs: Number(backendMs) || 0, boardMs: Number(boardMs) || 0 }, options) + : [], + [options, backendMs, boardMs], + ); + + const totals = new Map( + rows.map((r) => [r.board, boardTotals(r, connected.has(r.board), kaFlows, options.udp)]), + ); + 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 modifiedCount = (board: string) => Object.keys(periodOverrides[board] ?? {}).length; + const totalModified = Object.values(periodOverrides).reduce((s, m) => s + Object.keys(m).length, 0); + + // A period equal to the ADJ's (in any unit, e.g. 10 ms = 10000 us) or a + // reset drops the override entirely. + const setPeriod = (board: string, f: TrafficFlow, override: PeriodOverride | null) => + setPeriodOverrides((prev) => { + const forBoard = { ...prev[board] }; + const adjSeconds = periodSeconds(f.adjPeriod ?? f.period, f.adjPeriodUnit ?? f.periodUnit); + const newSeconds = override && periodSeconds(override.value, override.unit); + const sameAsAdj = + adjSeconds !== undefined && newSeconds != null && Math.abs(newSeconds - adjSeconds) <= adjSeconds * 1e-9; + if (override === null || sameAsAdj) delete forBoard[f.id!]; + else forBoard[f.id!] = override; + const next = { ...prev }; + if (Object.keys(forBoard).length > 0) next[board] = forBoard; + else delete next[board]; + return next; + }); + + const segmentsFor = (udp: (t: BoardTotals) => number, keepAlive: (t: BoardTotals) => number): Segment[] => { + const segments: Segment[] = []; + 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)!; + if (i < SERIES_SLOTS) { + segments.push({ key: r.board, label: r.board, color: seriesColor(i), udp: udp(t), keepAlive: keepAlive(t) }); + } else { + other.udp += udp(t); + other.keepAlive += keepAlive(t); + } + }); + if (other.udp + other.keepAlive > 0) segments.push(other); + return segments; + }; + + const linkUse = (t: BoardTotals) => (view === "direction" ? t.up : t.up + t.down); + + const setOption = (key: "udp" | "keepAlive" | "acks") => (v: boolean) => setOptions((o) => ({ ...o, [key]: v })); + const setCountAs = (countAs: CountAs) => setOptions((o) => ({ ...o, countAs })); + const toggleConnected = (board: string, v: boolean) => + setConnected((s) => { + const n = new Set(s); + if (v) n.add(board); + else n.delete(board); + return n; + }); + + return ( + + {/* items-start keeps the scenario panel at its own height; sticky keeps it + in view while the page scrolls through expanded boards. */} +
+ {/* Scenario */} + + + {/* Results */} +
+
+ {options.countAs === "wireshark" && ( +

+ Counting bytes as Wireshark shows them. The link itself carries more: preamble, inter-frame gap and FCS, + and padding on frames the backend sends. +

+ )} + {capacityBps === null ? ( +

Set a link capacity above 0 to draw the bars.

+ ) : view === "direction" ? ( + <> + t.udp, (t) => t.keepAliveUp)} + capacityBps={capacityBps} + /> + 0, (t) => t.keepAliveDown)} + capacityBps={capacityBps} + /> + + ) : ( + t.udp, (t) => t.keepAliveUp + t.keepAliveDown)} + capacityBps={capacityBps} + /> + )} +
+ {rows.map((r, i) => ( + + + {r.board} + + ))} + + + + UDP + + + + TCP keep-alive + + +
+
+ +
+ + + + + + + + + + + + + {rows.map((r, i) => { + const t = totals.get(r.board)!; + const isOpen = expanded === r.board; + const edited = modifiedCount(r.board); + return ( + + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + + + + + + + + + + +
Board + TCP connected + UDPTo backendFrom backend + {view === "direction" ? "Link use, to backend" : "Link use, both ways"} +
+ + {(r.skipped.length > 0 || edited > 0) && ( +
+ {r.skipped.length > 0 && ( + + + {r.skipped.length} packet{r.skipped.length === 1 ? "" : "s"} not counted + + )} + {edited > 0 && ( + + {edited} period{edited === 1 ? "" : "s"} edited + + )} +
+ )} +
+ toggleConnected(r.board, v === true)} + /> + {formatBitrate(t.udp)}{formatBitrate(t.up)}{formatBitrate(t.down)}{pctOf(linkUse(t), capacityBps)}
+ setPeriod(r.board, f, o)} + /> +
+ All boards + {formatBitrate(sum((t) => t.udp))}{formatBitrate(sum((t) => t.up))}{formatBitrate(sum((t) => t.down))}{pctOf(sum(linkUse), capacityBps)}
+
+
+
+
+ ); +} + +function BoardDetail({ + board, + options, + connected, + kaFlows, + totals, + capacityBps, + onPeriodChange, +}: { + board: BoardThroughput; + options: TrafficOptions; + connected: boolean; + kaFlows: TrafficFlow[]; + totals: BoardTotals; + capacityBps: number | null; + onPeriodChange: OnPeriodChange; +}) { + const note = (text: string) => ( +

{text}

+ ); + + return ( +
+
+
To backend
+
+ {formatBitrate(totals.udp)} UDP + {formatBitrate(totals.keepAliveUp)} keep-alive ={" "} + {formatBitrate(totals.up)} + , {pctOf(totals.up, capacityBps)} of the link +
+
From backend
+
+ {formatBitrate(totals.keepAliveDown)} keep-alive ={" "} + {formatBitrate(totals.down)} + , {pctOf(totals.down, capacityBps)} of the link +
+
+ + {!options.udp + ? note("UDP data packets are left out of the estimate.") + : board.packets.length > 0 + ? ( + a.id! - b.id!)} + onPeriodChange={onPeriodChange} + /> + ) + : note("This board has no periodic UDP packets.")} + + {!options.keepAlive + ? note("TCP keep-alive is left out of the estimate.") + : !connected + ? note("Not TCP connected, so no keep-alive traffic.") + : kaFlows.length > 0 + ? ( + + ) + : note("Both keep-alive intervals are off.")} + + {board.skipped.length > 0 && ( +
+ {board.skipped.map((s) => ( +
+ + {s.name} + + (ID {s.id}) is not counted: {s.reason} + +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/adj-view/src/adj/v2/sockets.ts b/frontend/adj-view/src/adj/v2/sockets.ts new file mode 100644 index 000000000..ca2e3773c --- /dev/null +++ b/frontend/adj-view/src/adj/v2/sockets.ts @@ -0,0 +1,65 @@ +// Socket helpers shared by the Network, Sockets and Throughput tabs. This is the +// only place that interprets an AdjSocket (protocol, role, which port is the +// board's, where remote_ip points), so all tabs agree. +import type { AdjSocket } from "./types"; + +export type Protocol = "TCP" | "UDP" | "OTHER"; + +export type SocketRole = "TCP server" | "TCP client" | "UDP" | "Unknown"; + +// Socket "type" comes straight from the ADJ archive (Java-style class names: +// ServerSocket = TCP server, Socket = TCP client, DatagramSocket = UDP) — derive +// protocol from it rather than hardcoding specific socket names. +export function protocolFromSocketType(type: string): Protocol { + const t = type.toLowerCase(); + if (t.includes("datagram") || t.includes("udp")) return "UDP"; + if (t.includes("socket") || t.includes("stream") || t.includes("tcp")) return "TCP"; + return "OTHER"; +} + +export function socketRole(type: string): SocketRole { + const protocol = protocolFromSocketType(type); + if (protocol === "UDP") return "UDP"; + if (protocol === "OTHER") return "Unknown"; + return type.toLowerCase().includes("server") ? "TCP server" : "TCP client"; +} + +// The port on the board's side: `port` for ServerSocket/DatagramSocket, +// `local_port` for a TCP client Socket. +export function boardPort(socket: AdjSocket): number | undefined { + return socket.port ?? socket.local_port; +} + +export interface SocketTarget { + kind: "board" | "address" | "ip"; + // Stable id: board name, address key, or `ip:` for an unknown IP. + id: string; + label: string; + ip: string; +} + +// A socket's remote_ip may be a symbolic key into `addresses` (e.g. "backend"), +// the raw IP of a known address, another board's IP (board-to-board traffic), +// or an IP the archive doesn't know about. +export function resolveTarget( + remoteIp: string, + addresses: Record, + boardByIp?: ReadonlyMap, +): SocketTarget { + const board = boardByIp?.get(remoteIp); + if (board) return { kind: "board", id: board, label: board, ip: remoteIp }; + if (remoteIp in addresses) { + return { kind: "address", id: remoteIp, label: remoteIp, ip: addresses[remoteIp] }; + } + const knownKey = Object.entries(addresses).find(([, ip]) => ip === remoteIp)?.[0]; + if (knownKey) return { kind: "address", id: knownKey, label: knownKey, ip: remoteIp }; + return { kind: "ip", id: `ip:${remoteIp}`, label: remoteIp, ip: remoteIp }; +} + +// Names from general_info.ports that use this port number (usually one). +export function portNames(port: number | undefined, ports: Record): string[] { + if (port == null) return []; + return Object.entries(ports) + .filter(([, p]) => p === port) + .map(([name]) => name); +} diff --git a/frontend/adj-view/src/adj/v2/summary.ts b/frontend/adj-view/src/adj/v2/summary.ts new file mode 100644 index 000000000..c2ff5df0d --- /dev/null +++ b/frontend/adj-view/src/adj/v2/summary.ts @@ -0,0 +1,11 @@ +import { extractBoards } from "./AdjViewerTabs"; +import type { AdjArchiveV2 } from "./types"; + +export function summarizeV2(data: AdjArchiveV2) { + const boards = extractBoards(data); + return { + boards: boards.length, + measurements: boards.reduce((s, b) => s + b.measurements.length, 0), + packets: boards.reduce((s, b) => s + b.packets.length + b.orders.length, 0), + }; +} diff --git a/frontend/adj-view/src/adj/v2/throughput.ts b/frontend/adj-view/src/adj/v2/throughput.ts new file mode 100644 index 000000000..e5c58a1d3 --- /dev/null +++ b/frontend/adj-view/src/adj/v2/throughput.ts @@ -0,0 +1,368 @@ +// Bandwidth estimate per board, derived from the ADJ v2 packet definitions plus +// the backend's TCP keep-alive. +// +// UDP wire format (backend: pkg/transport/presentation/decoder.go and +// pkg/transport/packet/data/codec.go): each UDP datagram carries exactly one +// packet = uint16 packet ID + the packet's variables in order, packed with no +// padding. Enums are sent as their uint8 variant index, bools as 1 byte. +// +// TCP keep-alive (backend: pkg/transport/keepalive.go): an empty packet with +// id 1 (just the 2-byte ID) sent to every connected board every +// tcp.keep_alive_interval_ms, and the boards send the same packet back. The +// connection has TCP_NODELAY set, so each keep-alive is its own segment. +import type { BoardMeta } from "./AdjViewerTabs"; +import { protocolFromSocketType } from "./sockets"; + +export const PACKET_ID_BYTES = 2; +export const KEEP_ALIVE_ID = 1; + +const TYPE_BYTES: Record = { + uint8: 1, + int8: 1, + bool: 1, + uint16: 2, + int16: 2, + uint32: 4, + int32: 4, + float32: 4, + uint64: 8, + int64: 8, + float64: 8, +}; + +function measurementBytes(type: string): number | undefined { + // Backend accepts any type starting with "enum" as an enum measurement. + if (type.startsWith("enum")) return 1; + return TYPE_BYTES[type]; +} + +export const PERIOD_UNIT_SECONDS: Record = { + ns: 1e-9, + us: 1e-6, + ms: 1e-3, + s: 1, +}; + +export const PERIOD_UNITS = Object.keys(PERIOD_UNIT_SECONDS); + +// Per-segment overhead, assuming IPv4 and TCP without options over Ethernet II +// without a VLAN tag. Ethernet pads its payload up to 46 bytes, so small +// segments cost more than their size suggests; preamble/SFD and the +// inter-frame gap occupy the link too, so they count toward bandwidth use. +export type Transport = "UDP" | "TCP"; +export const TRANSPORT_HEADER_BYTES: Record = { UDP: 8, TCP: 20 }; +export const IPV4_HEADER_BYTES = 20; +export const ETH_MIN_PAYLOAD_BYTES = 46; +// Largest IP packet in one Ethernet frame; bigger datagrams would fragment, +// which this model does not simulate. +export const ETH_MTU_BYTES = 1500; +export const ETH_HEADER_BYTES = 14; +export const ETH_FCS_BYTES = 4; +export const ETH_PREAMBLE_SFD_BYTES = 8; +export const ETH_INTERFRAME_GAP_BYTES = 12; + +// Which assumptions the estimate includes; all on is the worst case. +export interface TrafficOptions { + udp: boolean; + keepAlive: boolean; + // Every keep-alive acknowledged by its own pure-ACK segment instead of + // piggybacking on the other side's keep-alive. + acks: boolean; + countAs: CountAs; +} + +// How a frame's bytes are counted: +// - "wire": all the line time it takes — preamble/SFD, the frame with its FCS, +// and the inter-frame gap. This is what fills the link. +// - "wireshark": the frame length a capture on the backend host shows. NICs +// never hand over preamble, gap or FCS; frames the backend receives arrive +// already padded to the Ethernet minimum, but frames it sends are captured +// before its NIC pads them. +export type CountAs = "wire" | "wireshark"; + +// Name of the counted figure in tables and breakdowns. +export const COUNTED_LABEL: Record = { + wire: "On the wire", + wireshark: "In Wireshark", +}; + +export const DEFAULT_TRAFFIC_OPTIONS: TrafficOptions = { udp: true, keepAlive: true, acks: true, countAs: "wire" }; + +export interface WireBreakdown { + countAs: CountAs; + transport: Transport; + transportHeader: number; + payload: number; + ipPacket: number; + padding: number; + fcs: number; + frame: number; + preambleSfd: number; + interFrameGap: number; + total: number; +} + +// "up" = board → backend, "down" = backend → board. +export type Direction = "up" | "down"; + +export function wireBreakdown( + payloadBytes: number, + transport: Transport, + countAs: CountAs, + direction: Direction, +): WireBreakdown { + const onWire = countAs === "wire"; + const transportHeader = TRANSPORT_HEADER_BYTES[transport]; + const ipPacket = payloadBytes + transportHeader + IPV4_HEADER_BYTES; + // A capture on the backend sees its outgoing ("down") frames before the NIC pads them. + const padded = onWire || direction === "up"; + const padding = padded ? Math.max(0, ETH_MIN_PAYLOAD_BYTES - ipPacket) : 0; + const fcs = onWire ? ETH_FCS_BYTES : 0; + const frame = ETH_HEADER_BYTES + ipPacket + padding + fcs; + const preambleSfd = onWire ? ETH_PREAMBLE_SFD_BYTES : 0; + const interFrameGap = onWire ? ETH_INTERFRAME_GAP_BYTES : 0; + return { + countAs, + transport, + transportHeader, + payload: payloadBytes, + ipPacket, + padding, + fcs, + frame, + preambleSfd, + interFrameGap, + total: preambleSfd + frame + interFrameGap, + }; +} + +export interface PacketField { + id: string; + type: string; + bytes: number; +} + +// One periodic stream of identical frames: a UDP data packet, a TCP keep-alive +// or the ACKs it triggers. +export interface TrafficFlow { + key: string; + name: string; + id?: number; + transport: Transport; + direction: Direction; + period: number; + periodUnit: string; + // The ADJ's own period and unit when period/periodUnit are a what-if + // override; undefined otherwise. + adjPeriod?: number; + adjPeriodUnit?: string; + periodSeconds: number; + rateHz: number; + hasPacketId: boolean; + fields: PacketField[]; + payloadBytes: number; + wire: WireBreakdown; + wireBytes: number; + payloadBps: number; + wireBps: number; +} + +function makeFlow( + base: Pick< + TrafficFlow, + "key" | "name" | "id" | "transport" | "direction" | "period" | "periodUnit" | "adjPeriod" | "adjPeriodUnit" | "hasPacketId" | "fields" + >, + periodSeconds: number, + countAs: CountAs, +): TrafficFlow { + const payloadBytes = (base.hasPacketId ? PACKET_ID_BYTES : 0) + base.fields.reduce((s, f) => s + f.bytes, 0); + const rateHz = 1 / periodSeconds; + const wire = wireBreakdown(payloadBytes, base.transport, countAs, base.direction); + return { + ...base, + periodSeconds, + rateHz, + payloadBytes, + wire, + wireBytes: wire.total, + payloadBps: payloadBytes * 8 * rateHz, + wireBps: wire.total * 8 * rateHz, + }; +} + +// A UDP packet whose throughput can't be computed from its definition. +export interface SkippedPacket { + id: number; + name: string; + reason: string; +} + +export interface BoardThroughput { + board: string; + hasTcp: boolean; + packets: TrafficFlow[]; + skipped: SkippedPacket[]; + payloadBps: number; + wireBps: number; +} + +// What-if periods by packet id. They only feed the calculation; the ADJ itself +// is never modified. +export interface PeriodOverride { + value: number; + unit: string; +} +export type PeriodOverrides = ReadonlyMap; + +export function periodSeconds(value: number, unit: string): number | undefined { + const s = PERIOD_UNIT_SECONDS[unit]; + return s === undefined ? undefined : value * s; +} + +export function computeBoardThroughput( + board: BoardMeta, + countAs: CountAs, + periodOverrides?: PeriodOverrides, +): BoardThroughput { + const udpSockets = new Set( + board.sockets.filter((s) => protocolFromSocketType(s.type) === "UDP").map((s) => s.name), + ); + const typeById = new Map(board.measurements.map((m) => [m.id, m.type])); + + const packets: TrafficFlow[] = []; + const skipped: SkippedPacket[] = []; + + for (const p of board.packets) { + if (!p.socket || !udpSockets.has(p.socket)) continue; + const skip = (reason: string) => skipped.push({ id: p.id, name: p.name, reason }); + + const override = periodOverrides?.get(p.id); + const period = override?.value ?? p.period; + const unit = override?.unit ?? p.period_type ?? ""; + if (period == null || period <= 0) { + skip("no period"); + continue; + } + const seconds = periodSeconds(period, unit); + if (seconds === undefined) { + skip(`unknown period unit "${unit}"`); + continue; + } + + const fields: PacketField[] = []; + const problems: string[] = []; + for (const v of p.variables ?? []) { + const type = typeById.get(v); + const size = type === undefined ? undefined : measurementBytes(type); + if (type === undefined) problems.push(`unknown variable "${v}"`); + else if (size === undefined) problems.push(`unknown type "${type}" for "${v}"`); + else fields.push({ id: v, type, bytes: size }); + } + if (problems.length > 0) { + skip(problems.join(", ")); + continue; + } + + packets.push( + makeFlow( + { + key: `udp-${p.id}`, + name: p.name, + id: p.id, + transport: "UDP", + direction: "up", + period, + periodUnit: unit, + adjPeriod: override !== undefined ? p.period : undefined, + adjPeriodUnit: override !== undefined ? (p.period_type ?? "") : undefined, + hasPacketId: true, + fields, + }, + seconds, + countAs, + ), + ); + } + + return { + board: board.name, + hasTcp: board.sockets.some((s) => protocolFromSocketType(s.type) === "TCP"), + packets, + skipped, + payloadBps: packets.reduce((s, p) => s + p.payloadBps, 0), + wireBps: packets.reduce((s, p) => s + p.wireBps, 0), + }; +} + +export interface KeepAliveIntervals { + backendMs: number; + boardMs: number; +} + +// Per connected board. An interval that isn't > 0 disables that side's +// keep-alive and its ACKs. +export function keepAliveFlows( + { backendMs, boardMs }: KeepAliveIntervals, + { acks, countAs }: Pick, +): TrafficFlow[] { + const flows: TrafficFlow[] = []; + const add = (key: string, name: string, direction: Direction, ms: number, hasPacketId: boolean) => + flows.push( + makeFlow( + { + key, + name, + id: hasPacketId ? KEEP_ALIVE_ID : undefined, + transport: "TCP", + direction, + period: ms, + periodUnit: "ms", + hasPacketId, + fields: [], + }, + ms * 1e-3, + countAs, + ), + ); + + if (boardMs > 0) { + add("ka-board", "Board keep-alive", "up", boardMs, true); + if (acks) add("ack-board", "ACK of board keep-alive", "down", boardMs, false); + } + if (backendMs > 0) { + add("ka-backend", "Backend keep-alive", "down", backendMs, true); + if (acks) add("ack-backend", "ACK of backend keep-alive", "up", backendMs, false); + } + return flows; +} + +export function sumBps(flows: TrafficFlow[], direction: Direction): number { + return flows.filter((f) => f.direction === direction).reduce((s, f) => s + f.wireBps, 0); +} + +export interface BoardTotals { + udp: number; + keepAliveUp: number; + keepAliveDown: number; + up: number; + down: number; +} + +// kaFlows must already be empty when keep-alive is excluded. +export function boardTotals( + board: BoardThroughput, + connected: boolean, + kaFlows: TrafficFlow[], + includeUdp: boolean, +): BoardTotals { + const udp = includeUdp ? board.wireBps : 0; + const keepAliveUp = connected ? sumBps(kaFlows, "up") : 0; + const keepAliveDown = connected ? sumBps(kaFlows, "down") : 0; + return { udp, keepAliveUp, keepAliveDown, up: udp + keepAliveUp, down: keepAliveDown }; +} + +export function formatBitrate(bps: number): string { + if (bps >= 1e6) return `${(bps / 1e6).toFixed(2)} Mbit/s`; + if (bps >= 1e3) return `${(bps / 1e3).toFixed(2)} kbit/s`; + return `${bps.toFixed(0)} bit/s`; +} diff --git a/frontend/adj-view/src/types/adj.ts b/frontend/adj-view/src/adj/v2/types.ts similarity index 70% rename from frontend/adj-view/src/types/adj.ts rename to frontend/adj-view/src/adj/v2/types.ts index b8a1c40f0..64230fcdf 100644 --- a/frontend/adj-view/src/types/adj.ts +++ b/frontend/adj-view/src/adj/v2/types.ts @@ -1,4 +1,4 @@ -// Types for the ADJ archive fetched from GitHub Pages. +// Types for an ADJ v2 archive fetched from GitHub Pages. export interface AdjMeasurement { id: string; @@ -29,14 +29,20 @@ export interface AdjBoardInfo { packets: string[]; } -// A board's network socket. ServerSocket entries have no remote_ip — the board -// listens, but the archive doesn't record who connects. DatagramSocket entries -// always have remote_ip (a raw IP, or sometimes a key from general_info.addresses). +// A board's network socket. The ADJ schema (adj repo, +// .github/workflows/scripts/adj-tester/schema/socket.schema.json) allows three types: +// - ServerSocket (TCP server): `port`. No remote_ip — the board listens, and the +// archive doesn't record who connects. +// - DatagramSocket (UDP): `port`, `remote_ip`. +// - Socket (TCP client): `local_port`, `remote_ip`, `remote_port`. +// remote_ip is a raw IP or a key from general_info.addresses (e.g. "backend"). export interface AdjSocket { type: string; name: string; - port: number; + port?: number; + local_port?: number; remote_ip?: string; + remote_port?: number; } // boards[boardName] is a nested group, not a flat object. @@ -44,7 +50,7 @@ export interface AdjSocket { // "packets", "packets_old", "orders", "orders_old" (AdjPacket[]), "sockets" (AdjSocket[]). export type AdjBoardGroup = Record; -export interface AdjArchive { +export interface AdjArchiveV2 { boards: Record; general_info: { ports: Record; diff --git a/frontend/adj-view/src/adj/v2/ui.tsx b/frontend/adj-view/src/adj/v2/ui.tsx new file mode 100644 index 000000000..a844ff022 --- /dev/null +++ b/frontend/adj-view/src/adj/v2/ui.tsx @@ -0,0 +1,133 @@ +// Small presentational pieces shared by the v2 list tabs (Boards, Measurements, +// Packets, Sockets). +import { Input } from "@workspace/ui/components"; +import { ChevronDown, ChevronUp, Search } from "@workspace/ui/icons"; +import { cn } from "@workspace/ui/lib"; + +export type SortDir = "asc" | "desc"; + +export function Highlight({ text, query }: { text: string; query: string }) { + if (!query) return <>{text}; + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return <>{text}; + return ( + <> + {text.slice(0, idx)} + + {text.slice(idx, idx + query.length)} + + {text.slice(idx + query.length)} + + ); +} + +export function ResultCount({ n, total }: { n: number; total: number }) { + return ( + + {n === total ? total : `${n} / ${total}`} + + ); +} + +export function SearchInput({ + value, + onChange, + placeholder, + inputRef, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; + inputRef?: React.RefObject; +}) { + return ( +
+ + onChange(e.target.value)} + className="h-8 pl-8 pr-8 text-xs shadow-none focus-visible:ring-0" + /> + {value && ( + + )} +
+ ); +} + +export function SortableHeader({ + label, + col, + sortKey, + sortDir, + onSort, +}: { + label: string; + col: K; + sortKey: K; + sortDir: SortDir; + onSort: (col: K) => void; +}) { + const active = sortKey === col; + return ( + + + + ); +} + +export function BoardChip({ + name, + active, + onClick, +}: { + name: string; + active: boolean; + onClick: () => void; +}) { + return ( + + ); +} + +export function EmptyState({ text }: { text: string }) { + return ( +
+ + {text} +
+ ); +} diff --git a/frontend/adj-view/src/adj/v2/useKeyboardSearch.ts b/frontend/adj-view/src/adj/v2/useKeyboardSearch.ts new file mode 100644 index 000000000..c461c3dfc --- /dev/null +++ b/frontend/adj-view/src/adj/v2/useKeyboardSearch.ts @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +// The "/" key focuses the given search input, unless the user is already typing. +export function useKeyboardSearch(ref: React.RefObject) { + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "/" && document.activeElement?.tagName !== "INPUT" && document.activeElement?.tagName !== "TEXTAREA") { + e.preventDefault(); + ref.current?.focus(); + } + }; + document.addEventListener("keydown", handler); + return () => document.removeEventListener("keydown", handler); + }, [ref]); +} diff --git a/frontend/adj-view/src/components/AdjViewerPage.tsx b/frontend/adj-view/src/components/AdjViewerPage.tsx index da5924e24..a1c0c6006 100644 --- a/frontend/adj-view/src/components/AdjViewerPage.tsx +++ b/frontend/adj-view/src/components/AdjViewerPage.tsx @@ -15,17 +15,22 @@ import { TooltipProvider, TooltipTrigger, } from "@workspace/ui/components"; -import { BookOpen, GitCommit, Loader2, RefreshCw, SunMoon } from "@workspace/ui/icons"; +import { AlertTriangle, GitCommit, Loader2, RefreshCw, SunMoon } from "@workspace/ui/icons"; +import { cn } from "@workspace/ui/lib"; +import logo from "@workspace/ui/outreach/main/logo_icon.svg"; +// Black strokes on transparency; inverted in dark mode (software_white.png is 8000 px / 430 KB). +import softwareLogo from "@workspace/ui/outreach/main/software_black.png"; import { useCallback, useEffect, useState } from "react"; import { config } from "../../config"; +import { parseAdj, summarizeAdj, type ParsedAdj } from "../adj"; +import { AdjViewer } from "../adj/AdjViewer"; import { useBranches } from "../hooks/useBranches"; -import type { AdjArchive } from "../types/adj"; -import { AdjViewerTabs, extractBoards } from "./AdjViewerTabs"; +import { UnsupportedAdjNotice } from "./UnsupportedAdjNotice"; const ADJ_ARCHIVE_URL = (hash: string) => `https://hyperloop-upv.github.io/ADJ-Archive/storage/commit-${hash}.json`; -async function fetchAdjArchive(hash: string): Promise { +async function fetchAdjArchive(hash: string): Promise { const response = await fetch(ADJ_ARCHIVE_URL(hash)); if (!response.ok) throw new Error(`ADJ fetch failed: ${response.status}`); return response.json(); @@ -40,6 +45,27 @@ async function resolveBranchToCommit(branch: string): Promise { return data.commit.sha as string; } +// logo_icon.svg draws the mark in the middle ~49% of its 900×900 viewBox (a +// 340×439 box). Scale the image up inside a clipped square so the mark itself, +// not the empty canvas, is `size` px tall. +const LOGO_SCALE = 900 / 440; + +function TeamLogo({ size, alt = "Hyperloop UPV", className }: { size: number; alt?: string; className?: string }) { + return ( + + {alt} + + ); +} + interface AdjViewerPageProps { isDark: boolean; onToggleTheme: () => void; @@ -48,7 +74,7 @@ interface AdjViewerPageProps { export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { const [hashInput, setHashInput] = useState(""); const [commitHash, setCommitHash] = useState(null); - const [adjData, setAdjData] = useState(null); + const [parsed, setParsed] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -57,17 +83,16 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { const [selectedBranch, setSelectedBranch] = useState(null); const [resolvingBranch, setResolvingBranch] = useState(false); - const boards = adjData ? extractBoards(adjData) : []; - const totalMeasurements = boards.reduce((s, b) => s + b.measurements.length, 0); - const totalPackets = boards.reduce((s, b) => s + b.packets.length + b.orders.length, 0); + const adj = parsed?.supported ? parsed.adj : null; + const version = parsed ? (parsed.supported ? parsed.adj.version : parsed.version) : null; + const summary = adj ? summarizeAdj(adj) : null; const load = useCallback(async (hash: string) => { if (!hash) return; try { setLoading(true); setError(null); - const data = await fetchAdjArchive(hash); - setAdjData(data); + setParsed(parseAdj(await fetchAdjArchive(hash))); setCommitHash(hash); } catch (err) { setError(String(err)); @@ -104,26 +129,58 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { }, [load]); return ( -
-
- -

ADJ Viewer

- {commitHash && ( - - - {commitHash.slice(0, 7)} - - )} - {adjData && ( -
- {boards.length} boards - {totalMeasurements} measurements - {totalPackets} packets +
+ {/* Below lg the theme toggle sits beside the title and the load controls + take their own full-width row; from lg it's one row, toggle last. */} +
+
+
+ + + Hyperloop UPV Software
- )} +

ADJ Viewer

+ {commitHash && ( + + + {commitHash.slice(0, 7)} + + )} + {parsed && ( + + {!parsed.supported && } + ADJ v{version} + + )} + {summary && ( +
+ {summary.boards} boards + {summary.measurements} measurements + {summary.packets} packets +
+ )} +
-
-
+
+
{ if (e.key === "Enter") handleBranchSelect(branchInput.trim()); }} - className="h-8 w-[10rem] text-xs" + className="h-8 w-full text-xs lg:w-[10rem]" /> No branches found @@ -172,19 +229,23 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) {
- or + or - setHashInput(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleLoad()} - className="h-8 w-[16rem] font-mono text-xs" - /> - +
+ setHashInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLoad()} + className="h-8 min-w-0 flex-1 font-mono text-xs lg:w-[16rem] lg:flex-none" + /> + +
+
+
@@ -196,7 +257,7 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) {
-
+
{error && ( @@ -205,11 +266,13 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { )}
- {adjData ? ( - + {adj ? ( + + ) : parsed && !parsed.supported ? ( + ) : (
- + {loading ? "Loading archive…" : "Enter an ADJ commit hash to view its data."}
)} diff --git a/frontend/adj-view/src/components/UnsupportedAdjNotice.tsx b/frontend/adj-view/src/components/UnsupportedAdjNotice.tsx new file mode 100644 index 000000000..61b0ec8c8 --- /dev/null +++ b/frontend/adj-view/src/components/UnsupportedAdjNotice.tsx @@ -0,0 +1,27 @@ +import { AlertTriangle } from "@workspace/ui/icons"; +import { SUPPORTED_ADJ_VERSIONS } from "../adj"; + +export function UnsupportedAdjNotice({ version }: { version: number }) { + const supported = SUPPORTED_ADJ_VERSIONS.map((v) => `ADJ v${v}`).join(", "); + const isNewer = version > Math.max(...SUPPORTED_ADJ_VERSIONS); + + return ( +
+
+
+ + {isNewer ? `New ADJ version detected: v${version}` : `Unsupported ADJ version: v${version}`} +
+

+ {isNewer + ? `This archive uses ADJ v${version}, a newer format than this viewer understands.` + : `This archive uses ADJ v${version}, which this viewer doesn't understand.`}{" "} + {`The ADJ Viewer currently supports ${supported} only, so this archive's contents can't be displayed.`} +

+

+ To browse it, use a version of the ADJ Viewer with ADJ v{version} support, or load a commit that uses {supported}. +

+
+
+ ); +} diff --git a/frontend/adj-view/src/index.css b/frontend/adj-view/src/index.css index 0941bd220..ebe6a1c75 100644 --- a/frontend/adj-view/src/index.css +++ b/frontend/adj-view/src/index.css @@ -12,3 +12,30 @@ body { color: var(--foreground); background-color: var(--background); } + +/* Categorical series palette (validated for adjacent CVD separation on the + app's light #f9fafb and dark #181818 surfaces). Slot order is the safety + mechanism — assign in order, never reorder. */ +:root { + --series-1: #2a78d6; + --series-2: #eb6834; + --series-3: #1baf7a; + --series-4: #eda100; + --series-5: #e87ba4; + --series-6: #008300; + --series-7: #4a3aa7; + --series-8: #e34948; + --series-other: #9a9994; +} + +.dark { + --series-1: #3987e5; + --series-2: #d95926; + --series-3: #199e70; + --series-4: #c98500; + --series-5: #d55181; + --series-6: #008300; + --series-7: #9085e9; + --series-8: #e66767; + --series-other: #6f6e69; +} diff --git a/frontend/logging-view/CLAUDE.md b/frontend/logging-view/CLAUDE.md index 380499cc4..9ef79714c 100644 --- a/frontend/logging-view/CLAUDE.md +++ b/frontend/logging-view/CLAUDE.md @@ -6,77 +6,67 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ```bash pnpm dev # Dev server on port 9003 -pnpm build # tsc -b && vite build +pnpm build # tsc -b && vite build (a chunk-size warning is expected: Plotly is large) pnpm lint # ESLint pnpm preview # Preview production build ``` -Run from the monorepo root targeting this workspace: - -```bash -pnpm dev --filter logging-view -pnpm add --filter logging-view -``` +From the monorepo root: `pnpm dev --filter logging-view`, `pnpm build:logging-view`, `pnpm add --filter logging-view`. > **pnpm only** — the `preinstall` hook enforces this via `only-allow`. -## Architecture +There is no test suite; verify with `pnpm build` and `pnpm lint`. Lint currently reports ~100 warnings, almost all from the legacy `src/base/plot_gui_web/app.js` (see below) — judge a change by whether it adds warnings in `src/` outside `base/`. For UI changes, drive the dev server with Playwright (installed for the root `e2e` workspace) and screenshot the result; a session folder must be opened through the folder picker, so real log data is needed. -`logging-view` is one of several frontend workspaces in the monorepo (`frontend/`). It shares infrastructure with `competition-view` and `testing-view` but is a standalone Vite+React app running on its own port. +## What this app is -**Current status: scaffold, not yet wired up.** The store slices, types, and layout shell were carried over from `competition-view`, but `App.tsx` doesn't yet subscribe to any topics or define routes, and some sidebar components (`ConnectionStatusGroup`, `KeyboardShortcutsHelp`) import from `../hooks/useConnections` and `../hooks/useKeyboardShortcuts`, which don't exist in this workspace yet — check `competition-view/src` for the reference implementation before assuming a missing import is a bug. +An **offline analysis tool for backend log sessions**. The user opens a session folder written by the backend logger; the app reads its CSVs and the session's ADJ, and plots them. It has **no WebSocket or live backend connection**: the `telemetry`, `messages`, `catalog` and `connections` store slices and the `@workspace/core` dependency are leftovers from the `competition-view` scaffold and nothing reads them. -### Workspace dependencies +Routing is a `HashRouter` (`src/main.tsx`, needed when served from the Electron app). `/` redirects to `/simple`; `/normal` is a placeholder page ("still under development"). -| Package | Alias | What it provides | -|---|---|---| -| `frontend-kit/ui` | `@workspace/ui` | shadcn/Radix UI components, custom hooks (`useWebSocket`, `useTopic`), Lucide icons | -| `frontend-kit/core` | `@workspace/core` | WebSocket utilities, `socketService`, shared business logic | +## Session loading (`src/store/slices/sessionSlice.ts`) -Import components from the shared packages: +A session folder, chosen via `` or drag-and-drop in `FolderPickerGroup`, is expected to look like: -```tsx -import { Button, Sidebar } from "@workspace/ui/components"; -import { Plus } from "@workspace/ui/icons"; -import { socketService } from "@workspace/core"; +``` +/logger_settings.json { adj_commit_hash, time_unit: "ns"|"us"|"ms"|"s", date } +/data//.csv ``` -### State management +- CSVs have 4 columns: `timestamp, board, backend, value`. `parseCSV` (`src/lib/plotStudio/csv.ts`) converts timestamps to ms using `time_unit` and shifts them to start at 0. Text values (enum names, `true`/`false`) are mapped to numeric codes, using the ADJ `enumValues` when known. +- `openSession` runs in independent stages that never abort the whole load: settings → CSV scan → ADJ fetch (`https://hyperloop-upv.github.io/ADJ-Archive/storage/commit-.json`). The result is a `SessionStatus` of `ok` / `degraded` (no or malformed settings but CSVs found → CSV-only mode) / `error`, plus a separate ADJ status. Shown as a badge and a toast. +- Files are kept in `sessionFiles`, keyed by `webkitRelativePath`, and parsed lazily. `clearSession` also wipes all Plot Studio state derived from the session. +- The sidebar's "View ADJ" button calls `window.electronAPI.switchView("adj-view", { commit })` (typed in `src/vite-env.d.ts`); it only works inside the Electron app, which then opens `frontend/adj-view` with `?commit=`. -Single Zustand store composed of slices (`src/store/`). Only `isDarkMode` is persisted (localStorage key `competition-view-storage`). All other state is ephemeral. +## Plot Studio (simple mode) -| Slice | Purpose | -|---|---| -| `appSlice` | Dark mode toggle | -| `connectionsSlice` | WebSocket connection statuses (`Record`) | -| `messagesSlice` | Incoming log messages, capped at 500 entries (newest first) | -| `telemetrySlice` | Flat map of latest measurement values, flattened from `TelemetryData` packets | -| `catalogSlice` | Commands catalog fetched from backend (`GET /backend/orderStructures`) | +`src/components/simple/` is a React port of the legacy vanilla app in `src/base/plot_gui_web/`, which is kept as reference only: nothing imports it and it isn't built, but ESLint scans it. -### WebSocket integration +- **Layout:** the left app sidebar holds the session picker and the series list (`SeriesGroup`). The right studio panel (`StudioSidebar`) works like a VS Code activity bar with one open section at a time: Plots, Composed Series, FFT settings. Only the plots area scrolls. +- **Signals:** every session series is available without a load step. `useStudioSignals` parses a CSV **in a Web Worker** (`csv.worker.ts` via `parseCSVInWorker`) the first time it's used, and caches it in `studioFiles`. Signal IDs are `"BOARD/measId"` for session series and `"op_N"` / `"tr_N"` for composed ones (math operations and transforms, `lib/plotStudio/operations.ts`, `transforms.ts`). +- **Data shape:** `SeriesData` is structure-of-arrays `Float64Array`s (time in ms from 0, value), handed to Plotly without copies. Sessions reach hundreds of thousands of points, so: + - `PlotWrapper` downsamples with LTTB (`decimate.ts`) before rendering. + - Stats avoid `Math.min(...arr)`-style spreads, which overflow the stack. + - The series list is virtualized (`@tanstack/react-virtual`), with one shared `ContextMenu` per board instead of one per row. Mounting a menu per row froze the tab. +- **Plot modes:** line chart, FFT (`fft.ts`, sample-rate override in the FFT section), and "Cronograma", a Gantt-style state timeline for enum/bool signals. Eligibility comes from ADJ type metadata (`units.ts`), with a data-shape heuristic as fallback (`timeline.ts`). +- **Drag and drop:** series rows are dragged onto plot cards with dnd-kit (`useSignalDnd.ts`). The IDs travel in dnd-kit `data`, because the source (sidebar) and targets (plots) are unrelated subtrees. +- **Theming and export:** the on-screen Plotly theme follows dark mode (`plotlyTheme.ts`). Image exports always render in the light, serif "academic" theme with the current zoom range (`PlotWrapper` export code, `getExportFigure`). Trace colours are pinned to D3 Category10 (`palette.ts`), so sidebar chips and stats match the curves. The PDF report (`lib/pdfExport/`, jsPDF) exports all visible plots with an optional cover, index, statistics and annex. -Use hooks from `@workspace/core`/`@workspace/ui`: +## State -```tsx -import { useTopic, useWebSocket } from "@workspace/ui/hooks"; +A single Zustand store (`src/store/store.ts`) composed of slices. The ones in use are `sessionSlice`, `plotStudioSlice` and `appSlice`. Only `isDarkMode` is persisted, under the localStorage key `competition-view-storage`; the name is a scaffold leftover, and renaming it would reset users' theme. -const { isConnected } = useWebSocket(); +## Workspace dependencies -useTopic("podData/update", (data) => { - updateTelemetry(data); -}); +Components, icons and utilities come from `@workspace/ui` (`frontend-kit/ui`): -socketService.post("order/send", payload); +```tsx +import { Button, Sidebar } from "@workspace/ui/components"; +import { Plus } from "@workspace/ui/icons"; ``` -Relevant topics: `podData/update`, `connection/update`, `message/update`. - -### Layout structure - -`AppLayout` (sidebar + header shell) wraps all page content. The sidebar is collapsible-to-icon and has dark mode toggle in the footer. Pages/views go inside the `children` slot. +To add an icon: find it on lucide.dev, add its export to the file in `frontend-kit/ui/src/icons/` named after its **first** category, and re-export from `index.ts` if that file is new. -### Adding icons +## Gotchas -1. Find the icon on lucide.dev — note its **first category**. -2. Add the export to the matching category file in `frontend-kit/ui/src/icons/`. -3. Re-export from `index.ts` if the category file is new. \ No newline at end of file +- **Kit spacing tokens hijack named sizes:** `max-w-sm` and similar resolve to a few px because of the kit's `--spacing-*` tokens. Use arbitrary values like `max-w-[24rem]`. +- **SWC drops the leading space in multi-line JSX text:** in ` text…` where the text wraps onto more source lines, write `{" "}text…`.