From e91a382fa40cbf6debd2bd152da4aac4075cad65 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 18:53:16 +0200 Subject: [PATCH 1/5] feat: ADJ-viewer --- .github/workflows/frontend-tests.yaml | 2 +- .github/workflows/release.yaml | 46 ++++++ electron-app/.gitignore | 1 + electron-app/build.mjs | 9 ++ electron-app/package.json | 1 + electron-app/preload.js | 5 +- .../renderer/mode-selector/index.html | 1 + electron-app/src/app/modeSelector.js | 1 + electron-app/src/ipc/handlers.js | 7 +- electron-app/src/windows/mainWindow.js | 14 +- frontend/adj-view/eslint.config.js | 3 + frontend/adj-view/index.html | 15 ++ frontend/adj-view/package.json | 31 ++++ frontend/adj-view/src/App.tsx | 24 ++++ .../adj-view/src/components/AdjViewerPage.tsx | 132 ++++++++++++++++++ .../src/components/AdjViewerTabs.tsx} | 123 ++++++---------- frontend/adj-view/src/index.css | 14 ++ frontend/adj-view/src/main.tsx | 10 ++ frontend/adj-view/src/types/adj.ts | 46 ++++++ frontend/adj-view/src/vite-env.d.ts | 1 + frontend/adj-view/tsconfig.app.json | 23 +++ frontend/adj-view/tsconfig.json | 14 ++ frontend/adj-view/tsconfig.node.json | 21 +++ frontend/adj-view/vite.config.ts | 12 ++ .../components/sidebar/FolderPickerGroup.tsx | 26 ++-- frontend/logging-view/src/vite-env.d.ts | 12 ++ package.json | 1 + pnpm-lock.yaml | 62 ++++++-- pnpm-workspace.yaml | 1 + 29 files changed, 548 insertions(+), 110 deletions(-) create mode 100644 frontend/adj-view/eslint.config.js create mode 100644 frontend/adj-view/index.html create mode 100644 frontend/adj-view/package.json create mode 100644 frontend/adj-view/src/App.tsx create mode 100644 frontend/adj-view/src/components/AdjViewerPage.tsx rename frontend/{logging-view/src/components/sidebar/AdjViewerDialog.tsx => adj-view/src/components/AdjViewerTabs.tsx} (87%) create mode 100644 frontend/adj-view/src/index.css create mode 100644 frontend/adj-view/src/main.tsx create mode 100644 frontend/adj-view/src/types/adj.ts create mode 100644 frontend/adj-view/src/vite-env.d.ts create mode 100644 frontend/adj-view/tsconfig.app.json create mode 100644 frontend/adj-view/tsconfig.json create mode 100644 frontend/adj-view/tsconfig.node.json create mode 100644 frontend/adj-view/vite.config.ts diff --git a/.github/workflows/frontend-tests.yaml b/.github/workflows/frontend-tests.yaml index 496faddda..4ee2617d7 100644 --- a/.github/workflows/frontend-tests.yaml +++ b/.github/workflows/frontend-tests.yaml @@ -29,7 +29,7 @@ jobs: cache: "pnpm" - name: Install dependencies - run: pnpm install --frozen-lockfile --filter=testing-view --filter=flashing-view --filter=competition-view --filter=logging-view --filter=ui --filter=core + run: pnpm install --frozen-lockfile --filter=testing-view --filter=flashing-view --filter=competition-view --filter=logging-view --filter=adj-view --filter=ui --filter=core - name: Build frontend run: pnpm build --filter="./frontend/**" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fcfc670a1..11e23b750 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -22,6 +22,14 @@ on: description: "Include Flashing View" type: boolean default: true + include-logging: + description: "Include Logging View" + type: boolean + default: true + include-adj: + description: "Include ADJ Viewer" + type: boolean + default: true jobs: @@ -34,6 +42,8 @@ jobs: include_testing: ${{ steps.get_version.outputs.include_testing }} include_competition: ${{ steps.get_version.outputs.include_competition }} include_flashing: ${{ steps.get_version.outputs.include_flashing }} + include_logging: ${{ steps.get_version.outputs.include_logging }} + include_adj: ${{ steps.get_version.outputs.include_adj }} steps: - name: Determine version id: get_version @@ -43,6 +53,8 @@ jobs: echo "include_testing=${{ github.event.inputs.include-testing }}" >> $GITHUB_OUTPUT echo "include_competition=${{ github.event.inputs.include-competition }}" >> $GITHUB_OUTPUT echo "include_flashing=${{ github.event.inputs.include-flashing }}" >> $GITHUB_OUTPUT + echo "include_logging=${{ github.event.inputs.include-logging }}" >> $GITHUB_OUTPUT + echo "include_adj=${{ github.event.inputs.include-adj }}" >> $GITHUB_OUTPUT create-draft-release: name: Create Draft Release @@ -95,6 +107,12 @@ jobs: if [ "${{ needs.determine-version.outputs.include_flashing }}" = "true" ]; then FILTERS="$FILTERS --filter=flashing-view" fi + if [ "${{ needs.determine-version.outputs.include_logging }}" = "true" ]; then + FILTERS="$FILTERS --filter=logging-view" + fi + if [ "${{ needs.determine-version.outputs.include_adj }}" = "true" ]; then + FILTERS="$FILTERS --filter=adj-view" + fi pnpm turbo build $FILTERS - uses: actions/upload-artifact@v4 @@ -118,6 +136,20 @@ jobs: path: frontend/flashing-view/dist/** retention-days: 1 + - uses: actions/upload-artifact@v4 + if: needs.determine-version.outputs.include_logging == 'true' + with: + name: logging-dist + path: frontend/logging-view/dist/** + retention-days: 1 + + - uses: actions/upload-artifact@v4 + if: needs.determine-version.outputs.include_adj == 'true' + with: + name: adj-dist + path: frontend/adj-view/dist/** + retention-days: 1 + build-backend: name: Build Backend - ${{ matrix.os }} needs: determine-version @@ -300,6 +332,20 @@ jobs: name: flashing-dist path: electron-app/renderer/flashing-view + - name: Download logging-view dist + if: needs.determine-version.outputs.include_logging == 'true' + uses: actions/download-artifact@v4 + with: + name: logging-dist + path: electron-app/renderer/logging-view + + - name: Download adj-view dist + if: needs.determine-version.outputs.include_adj == 'true' + uses: actions/download-artifact@v4 + with: + name: adj-dist + path: electron-app/renderer/adj-view + - uses: pnpm/action-setup@v4 with: version: 10.26.0 diff --git a/electron-app/.gitignore b/electron-app/.gitignore index 9a3bc264a..90261cfe8 100644 --- a/electron-app/.gitignore +++ b/electron-app/.gitignore @@ -12,6 +12,7 @@ renderer/testing-view/ renderer/flashing-view/ renderer/competition-view/ renderer/logging-view/ +renderer/adj-view/ # include mode-selector in the build output !renderer/mode-selector/ !renderer/mode-selector/** diff --git a/electron-app/build.mjs b/electron-app/build.mjs index 12e2c2ef8..3a34361db 100644 --- a/electron-app/build.mjs +++ b/electron-app/build.mjs @@ -99,6 +99,15 @@ const CONFIG = { "pnpm run build", ], }, + "adj-view": { + type: "frontend", + path: join(ROOT, "frontend/adj-view"), + dest: join(__dirname, "renderer/adj-view"), + commands: [ + "pnpm --filter adj-view install --frozen-lockfile", + "pnpm run build", + ], + }, }; // --- Helpers --- diff --git a/electron-app/package.json b/electron-app/package.json index d1204300f..c60c8bdfe 100644 --- a/electron-app/package.json +++ b/electron-app/package.json @@ -45,6 +45,7 @@ "build:competition": "node build.mjs --competition-view", "build:flashing": "node build.mjs --flashing-view", "build:logging": "node build.mjs --logging-view", + "build:adj": "node build.mjs --adj-view", "asar:win": "asar list dist/win-unpacked/resources/app.asar | findstr /V node_modules", "asar:mac": "asar list dist/mac-unpacked/resources/app.asar | findstr /V node_modules", "asar:linux": "asar list dist/linux-unpacked/resources/app.asar | findstr /V node_modules" diff --git a/electron-app/preload.js b/electron-app/preload.js index 0ed097df6..d5606a8f0 100644 --- a/electron-app/preload.js +++ b/electron-app/preload.js @@ -26,8 +26,9 @@ const { contextBridge, ipcRenderer } = require("electron"); contextBridge.exposeInMainWorld("electronAPI", { // Get the currently loaded view name getCurrentView: () => ipcRenderer.invoke("get-current-view"), - // Switch to a different view by name - switchView: (view) => ipcRenderer.invoke("switch-view", view), + // Switch to a different view by name, optionally passing query params + // through to the loaded view's URL (e.g. an ADJ commit hash) + switchView: (view, query) => ipcRenderer.invoke("switch-view", view, query), // Save configuration object to file saveConfig: (config) => ipcRenderer.invoke("save-config", config), // Get current configuration object diff --git a/electron-app/renderer/mode-selector/index.html b/electron-app/renderer/mode-selector/index.html index 404f854be..ffb1038c0 100644 --- a/electron-app/renderer/mode-selector/index.html +++ b/electron-app/renderer/mode-selector/index.html @@ -267,6 +267,7 @@

Control Station

{ mode: 'competition', label: 'Competition View' }, { mode: 'flashing', label: 'Flashing View' }, { mode: 'logging', label: 'Logging View' }, + { mode: 'adj', label: 'ADJ Viewer' }, ]; async function renderButtons() { diff --git a/electron-app/src/app/modeSelector.js b/electron-app/src/app/modeSelector.js index b3728d71c..526965ce0 100644 --- a/electron-app/src/app/modeSelector.js +++ b/electron-app/src/app/modeSelector.js @@ -18,6 +18,7 @@ const VALID_MODES = { flashing: "flashing-view", competition: "competition-view", logging: "logging-view", + adj: "adj-view", default: "testing-view", }; diff --git a/electron-app/src/ipc/handlers.js b/electron-app/src/ipc/handlers.js index 378f537e0..66f55b32e 100644 --- a/electron-app/src/ipc/handlers.js +++ b/electron-app/src/ipc/handlers.js @@ -105,6 +105,7 @@ function setupIpcHandlers() { { mode: "competition", label: "Competition View" }, { mode: "flashing", label: "Flashing View" }, { mode: "logging", label: "Logging View" }, + { mode: "adj", label: "ADJ Viewer" }, ]; const rendererDir = join(getAppPath(), "renderer"); return ALL_VIEWS.filter(({ mode }) => @@ -117,10 +118,12 @@ function setupIpcHandlers() { * @description Switches the main window to the specified view. * @param {import("electron").IpcMainInvokeEvent} event - The IPC event object. * @param {string} view - The name of the view to switch to (e.g., "ethernet-view", "control-station"). + * @param {Record} [query] - Optional query params passed through to the + * loaded view's URL (e.g. `{ commit: hash }` so adj-view can auto-load a commit). * @returns {string} The view name that was loaded. */ - ipcMain.handle("switch-view", (event, view) => { - loadView(view); + ipcMain.handle("switch-view", (event, view, query) => { + loadView(view, query ? { query } : undefined); return view; }); diff --git a/electron-app/src/windows/mainWindow.js b/electron-app/src/windows/mainWindow.js index 15d8095ca..ca46432c7 100644 --- a/electron-app/src/windows/mainWindow.js +++ b/electron-app/src/windows/mainWindow.js @@ -92,12 +92,15 @@ function createWindow(screenWidth, screenHeight, initialView) { /** * Loads a specific view into the main window. * @param {string} view - The name of the view to load (e.g., "ethernet-view", "control-station"). + * @param {{ query?: Record }} [opts] - Optional load options. + * `query` is appended as a query string (e.g. adj-view's initial commit hash), + * readable in the renderer via `new URLSearchParams(window.location.search)`. * @returns {void} * @example * loadView("control-station"); - * loadView("ethernet-view"); + * loadView("adj-view", { query: { commit: "abc123" } }); */ -function loadView(view) { +function loadView(view, opts = {}) { // Update current view tracking currentView = view; // Construct path to view HTML file @@ -108,13 +111,18 @@ function loadView(view) { // Check if view file exists if (fs.existsSync(viewPath)) { // Load the view HTML file - mainWindow.loadFile(viewPath); + if (opts.query) { + mainWindow.loadFile(viewPath, { query: opts.query }); + } else { + mainWindow.loadFile(viewPath); + } // Update window title based on view type const titles = { "competition-view": "Competition View", "testing-view": "Testing View", "flashing-view": "Flashing View", "logging-view": "Logging View", + "adj-view": "ADJ Viewer", }; mainWindow.setTitle( `Hyperloop Control Station - ${titles[view] ?? view}`, diff --git a/frontend/adj-view/eslint.config.js b/frontend/adj-view/eslint.config.js new file mode 100644 index 000000000..df360b435 --- /dev/null +++ b/frontend/adj-view/eslint.config.js @@ -0,0 +1,3 @@ +import { config } from "@workspace/eslint-config/vite"; + +export default config; diff --git a/frontend/adj-view/index.html b/frontend/adj-view/index.html new file mode 100644 index 000000000..e4ff7bec9 --- /dev/null +++ b/frontend/adj-view/index.html @@ -0,0 +1,15 @@ + + + + + + + Hyperloop UPV ADJ Viewer + + + +
+ + + + diff --git a/frontend/adj-view/package.json b/frontend/adj-view/package.json new file mode 100644 index 000000000..21d527ed4 --- /dev/null +++ b/frontend/adj-view/package.json @@ -0,0 +1,31 @@ +{ + "name": "adj-view", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "preinstall": "npx only-allow pnpm", + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.18", + "@vitejs/plugin-react-swc": "^4.2.3", + "@workspace/ui": "workspace:*", + "lucide-react": "^0.563.0", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.1.18" + }, + "devDependencies": { + "@types/react": "19.2.11", + "@types/react-dom": "19.2.3", + "@workspace/eslint-config": "workspace:^", + "@workspace/typescript-config": "workspace:*", + "eslint": "^9.39.2", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/frontend/adj-view/src/App.tsx b/frontend/adj-view/src/App.tsx new file mode 100644 index 000000000..d0d574c39 --- /dev/null +++ b/frontend/adj-view/src/App.tsx @@ -0,0 +1,24 @@ +import { useCallback, useEffect, useState } from "react"; +import { AdjViewerPage } from "./components/AdjViewerPage"; + +export default function App() { + const [isDark, setIsDark] = useState(() => { + const saved = localStorage.getItem("adj-view-dark-mode"); + return saved !== null + ? saved === "true" + : window.matchMedia("(prefers-color-scheme: dark)").matches; + }); + + useEffect(() => { + document.documentElement.classList.toggle("dark", isDark); + localStorage.setItem("adj-view-dark-mode", String(isDark)); + }, [isDark]); + + const toggleTheme = useCallback(() => setIsDark((d) => !d), []); + + return ( +
+ +
+ ); +} diff --git a/frontend/adj-view/src/components/AdjViewerPage.tsx b/frontend/adj-view/src/components/AdjViewerPage.tsx new file mode 100644 index 000000000..af793a46c --- /dev/null +++ b/frontend/adj-view/src/components/AdjViewerPage.tsx @@ -0,0 +1,132 @@ +// Standalone ADJ Viewer page: fetch an ADJ archive by commit hash and browse it. +// No session/store dependency — this app only ever knows what the user types in. +import { + Badge, + Button, + Input, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@workspace/ui/components"; +import { BookOpen, GitCommit, Loader2, SunMoon } from "@workspace/ui/icons"; +import { useCallback, useEffect, useState } from "react"; +import type { AdjArchive } from "../types/adj"; +import { AdjViewerTabs, extractBoards } from "./AdjViewerTabs"; + +const ADJ_ARCHIVE_URL = (hash: string) => + `https://hyperloop-upv.github.io/ADJ-Archive/storage/commit-${hash}.json`; + +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(); +} + +interface AdjViewerPageProps { + isDark: boolean; + onToggleTheme: () => void; +} + +export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { + const [hashInput, setHashInput] = useState(""); + const [commitHash, setCommitHash] = useState(null); + const [adjData, setAdjData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + 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 load = useCallback(async (hash: string) => { + if (!hash) return; + try { + setLoading(true); + setError(null); + const data = await fetchAdjArchive(hash); + setAdjData(data); + setCommitHash(hash); + } catch (err) { + setError(String(err)); + } finally { + setLoading(false); + } + }, []); + + const handleLoad = () => load(hashInput.trim()); + + // If launched from logging-view's "View ADJ" shortcut, the main process + // passes the session's commit hash through as a query param — load it + // immediately instead of waiting for the user to type it in. + useEffect(() => { + const commit = new URLSearchParams(window.location.search).get("commit"); + if (commit) { + setHashInput(commit); + load(commit); + } + }, [load]); + + return ( +
+
+ +

ADJ Viewer

+ {commitHash && ( + + + {commitHash.slice(0, 7)} + + )} + {adjData && ( +
+ {boards.length} boards + {totalMeasurements} measurements + {totalPackets} packets +
+ )} + +
+ setHashInput(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleLoad()} + className="h-8 w-[16rem] font-mono text-xs" + /> + + + + + + + + {isDark ? "Switch to light mode" : "Switch to dark mode"} + + +
+
+ + {error && ( + + {error} + + )} + +
+ {adjData ? ( + + ) : ( +
+ + {loading ? "Loading archive…" : "Enter an ADJ commit hash to view its data."} +
+ )} +
+
+ ); +} diff --git a/frontend/logging-view/src/components/sidebar/AdjViewerDialog.tsx b/frontend/adj-view/src/components/AdjViewerTabs.tsx similarity index 87% rename from frontend/logging-view/src/components/sidebar/AdjViewerDialog.tsx rename to frontend/adj-view/src/components/AdjViewerTabs.tsx index 45d739c87..eb75c642d 100644 --- a/frontend/logging-view/src/components/sidebar/AdjViewerDialog.tsx +++ b/frontend/adj-view/src/components/AdjViewerTabs.tsx @@ -1,10 +1,8 @@ +// Tab-based ADJ archive browser (Boards / Measurements / Packets / General). +// Pure data-in component — the page hosting it owns commit-hash fetching, +// loading/error states, and header chrome. import { Badge, - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogTrigger, Input, Tabs, TabsContent, @@ -13,7 +11,6 @@ import { } from "@workspace/ui/components"; import { Activity, - BookOpen, ChevronDown, ChevronRight, ChevronUp, @@ -25,12 +22,11 @@ import { } from "@workspace/ui/icons"; import { cn, getTypeBadgeClass, typeBadgeClasses } from "@workspace/ui/lib"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useStore } from "../../store/store"; -import type { AdjArchive, AdjMeasurement, AdjPacket } from "../../types/session"; +import type { AdjArchive, AdjMeasurement, AdjPacket } from "../types/adj"; // ─── types ─────────────────────────────────────────────────────────────────── -type BoardMeta = { +export type BoardMeta = { name: string; id: number; ip: string; @@ -44,7 +40,7 @@ type SortDir = "asc" | "desc"; // ─── data helpers ───────────────────────────────────────────────────────────── -function extractBoards(adjData: AdjArchive): BoardMeta[] { +export function extractBoards(adjData: AdjArchive): BoardMeta[] { return Object.entries(adjData.boards) .map(([boardName, boardGroup]) => { const g = boardGroup as Record; @@ -751,17 +747,10 @@ function useKeyboardSearch(ref: React.RefObject) { }, [ref]); } -// ─── main dialog ───────────────────────────────────────────────────────────── +// ─── main tabs component ───────────────────────────────────────────────────── -interface AdjViewerDialogProps { - children: React.ReactNode; -} - -export const AdjViewerDialog = ({ children }: AdjViewerDialogProps) => { - const adjData = useStore((s) => s.adjData); - const settings = useStore((s) => s.settings); - - const boards = useMemo(() => (adjData ? extractBoards(adjData) : []), [adjData]); +export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { + const boards = useMemo(() => extractBoards(adjData), [adjData]); // Lifted state for cross-tab navigation const [activeTab, setActiveTab] = useState("boards"); @@ -772,70 +761,36 @@ export const AdjViewerDialog = ({ children }: AdjViewerDialogProps) => { setActiveTab("measurements"); }, []); - 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); - return ( - - {children} - {/* sm:max-w-4xl overrides default sm:max-w-lg; flex flex-col overrides grid */} - - - - - ADJ Viewer - {settings && ( - - {settings.adj_commit_hash.slice(0, 7)} - - )} - {adjData && ( -
- {boards.length} boards - {totalMeasurements} measurements - {totalPackets} packets -
- )} -
-
- - {adjData ? ( - - - - Boards - - - Measurements - - - Packets - - - General - - - - - - - {/* key remounts MeasurementsTab on cross-tab jump so initialBoardFilter takes effect cleanly */} - - - - - - - - - - - ) : ( -
- No session loaded. -
- )} -
-
+ + + + Boards + + + Measurements + + + Packets + + + General + + + + + + + {/* key remounts MeasurementsTab on cross-tab jump so initialBoardFilter takes effect cleanly */} + + + + + + + + + + ); }; diff --git a/frontend/adj-view/src/index.css b/frontend/adj-view/src/index.css new file mode 100644 index 000000000..0941bd220 --- /dev/null +++ b/frontend/adj-view/src/index.css @@ -0,0 +1,14 @@ +@import "@workspace/ui/globals.css"; + +/* Ensure Tailwind v4 scans adj-view source files directly. + The @source paths in globals.css resolve via the @workspace/ui symlink + and don't correctly reach this project's source tree. */ +@source "./**/*.{ts,tsx}"; + +/* Apply base foreground color so text inherits correctly in both light and dark + mode. Without this, text falls back to the browser UA default (black), which + is invisible on the dark mode background (#181818). */ +body { + color: var(--foreground); + background-color: var(--background); +} diff --git a/frontend/adj-view/src/main.tsx b/frontend/adj-view/src/main.tsx new file mode 100644 index 000000000..ef474bf64 --- /dev/null +++ b/frontend/adj-view/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import App from "./App.tsx"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/frontend/adj-view/src/types/adj.ts b/frontend/adj-view/src/types/adj.ts new file mode 100644 index 000000000..1af0c761c --- /dev/null +++ b/frontend/adj-view/src/types/adj.ts @@ -0,0 +1,46 @@ +// Types for the ADJ archive fetched from GitHub Pages. + +export interface AdjMeasurement { + id: string; + name: string; + type: string; + podUnits?: string; + displayUnits?: string; + enumValues?: string[]; +} + +export interface AdjPacket { + id: number; + name: string; + // "data" packets carry telemetry; "order" packets are commands sent to a board. + type: "data" | "order" | string; + variables: string[]; + period?: number; + period_type?: string; + socket?: string; +} + +// The inner board config object stored under the board's own name key. +export interface AdjBoardInfo { + board_id: number; + board_ip: string; + // Path references to definition files — not embedded objects. + measurements: string[]; + packets: string[]; +} + +// boards[boardName] is a nested group, not a flat object. +// Keys: boardName (AdjBoardInfo), `${boardName}_measurements` (AdjMeasurement[]), +// "packets", "packets_old", "orders", "orders_old" (AdjPacket[]). +export type AdjBoardGroup = Record; + +export interface AdjArchive { + boards: Record; + general_info: { + ports: Record; + addresses: Record; + // Unit conversion expressions (e.g. "/1000" to convert mm→m). CONVERSION ALREADY MADE BY THE BACKEND. + units: Record; + message_ids: Record; + }; +} diff --git a/frontend/adj-view/src/vite-env.d.ts b/frontend/adj-view/src/vite-env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/frontend/adj-view/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/adj-view/tsconfig.app.json b/frontend/adj-view/tsconfig.app.json new file mode 100644 index 000000000..87fbd9630 --- /dev/null +++ b/frontend/adj-view/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/adj-view/tsconfig.json b/frontend/adj-view/tsconfig.json new file mode 100644 index 000000000..fc0aa4474 --- /dev/null +++ b/frontend/adj-view/tsconfig.json @@ -0,0 +1,14 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@workspace/ui/*": ["../frontend-kit/ui/src/*"] + } + }, + "exclude": ["node_modules"] +} diff --git a/frontend/adj-view/tsconfig.node.json b/frontend/adj-view/tsconfig.node.json new file mode 100644 index 000000000..22eb7aeef --- /dev/null +++ b/frontend/adj-view/tsconfig.node.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/adj-view/vite.config.ts b/frontend/adj-view/vite.config.ts new file mode 100644 index 000000000..5e9861567 --- /dev/null +++ b/frontend/adj-view/vite.config.ts @@ -0,0 +1,12 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react-swc"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + base: "./", + server: { + port: 9004, // ADJ Viewer = 9004 + host: true, + }, +}); diff --git a/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx b/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx index 82cf6bbc2..8e417e3c1 100644 --- a/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx +++ b/frontend/logging-view/src/components/sidebar/FolderPickerGroup.tsx @@ -15,7 +15,6 @@ import { cn } from "@workspace/ui/lib"; import { useCallback, useRef, useState } from "react"; import { useStore } from "../../store/store"; import type { DroppedFile } from "../../types/session"; -import { AdjViewerDialog } from "./AdjViewerDialog"; // Backend writes dates as "2025-06-15T13-45-22" (dashes in time part). function formatSessionDate(raw: string): string { @@ -208,15 +207,22 @@ const FolderPickerGroup = () => { - - - + )} diff --git a/frontend/logging-view/src/vite-env.d.ts b/frontend/logging-view/src/vite-env.d.ts index 11f02fe2a..0e65dd578 100644 --- a/frontend/logging-view/src/vite-env.d.ts +++ b/frontend/logging-view/src/vite-env.d.ts @@ -1 +1,13 @@ /// + +interface ElectronAPI { + switchView: (view: string, query?: Record) => Promise; +} + +declare global { + interface Window { + electronAPI?: ElectronAPI; + } +} + +export {}; diff --git a/package.json b/package.json index fbc52d986..02a60047e 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build:competition-view": "turbo build --filter=competition-view", "build:flashing-view": "turbo build --filter=flashing-view", "build:logging-view": "turbo build --filter=logging-view", + "build:adj-view": "turbo build --filter=adj-view", "build:win": "pnpm --filter hyperloop-control-station build:win", "build:linux": "pnpm --filter hyperloop-control-station build:linux", "build:mac": "pnpm --filter hyperloop-control-station build:mac", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed0c892d1..62b462a5e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,52 @@ importers: specifier: ^4.0.18 version: 4.0.18(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@25.2.0)(typescript@6.0.3)) + frontend/adj-view: + dependencies: + '@tailwindcss/vite': + specifier: ^4.1.18 + version: 4.3.0(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)) + '@vitejs/plugin-react-swc': + specifier: ^4.2.3 + version: 4.2.3(vite@7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0)) + '@workspace/ui': + specifier: workspace:* + version: link:../frontend-kit/ui + lucide-react: + specifier: ^0.563.0 + version: 0.563.0(react@19.2.4) + react: + specifier: ^19.2.4 + version: 19.2.4 + react-dom: + specifier: ^19.2.4 + version: 19.2.4(react@19.2.4) + tailwindcss: + specifier: ^4.1.18 + version: 4.3.0 + devDependencies: + '@types/react': + specifier: 19.2.11 + version: 19.2.11 + '@types/react-dom': + specifier: 19.2.3 + version: 19.2.3(@types/react@19.2.11) + '@workspace/eslint-config': + specifier: workspace:^ + version: link:../frontend-kit/esling-config + '@workspace/typescript-config': + specifier: workspace:* + version: link:../frontend-kit/typescript-config + eslint: + specifier: ^9.39.2 + version: 9.39.4(jiti@2.6.1) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^7.3.1 + version: 7.3.1(@types/node@25.2.0)(jiti@2.6.1)(lightningcss@1.32.0) + frontend/competition-view: dependencies: '@tailwindcss/vite': @@ -5865,8 +5911,8 @@ snapshots: '@develar/schema-utils@2.6.5': dependencies: - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) + ajv: 6.15.0 + ajv-keywords: 3.5.2(ajv@6.15.0) '@dnd-kit/accessibility@3.1.1(react@19.2.4)': dependencies: @@ -5897,7 +5943,7 @@ snapshots: dependencies: commander: 5.1.0 glob: 7.2.3 - minimatch: 3.1.2 + minimatch: 3.1.5 '@electron/fuses@1.8.0': dependencies: @@ -6107,7 +6153,7 @@ snapshots: dependencies: '@eslint/object-schema': 2.1.7 debug: 4.4.3 - minimatch: 3.1.2 + minimatch: 3.1.5 transitivePeerDependencies: - supports-color @@ -6129,14 +6175,14 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: - ajv: 6.12.6 + ajv: 6.15.0 debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 js-yaml: 4.1.1 - minimatch: 3.1.2 + minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: - supports-color @@ -7789,9 +7835,9 @@ snapshots: optionalDependencies: ajv: 8.17.1 - ajv-keywords@3.5.2(ajv@6.12.6): + ajv-keywords@3.5.2(ajv@6.15.0): dependencies: - ajv: 6.12.6 + ajv: 6.15.0 ajv@6.12.6: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f10a6df41..7ccb771cc 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,6 +5,7 @@ packages: - "frontend/competition-view" - "frontend/logging-view" - "frontend/flashing-view" + - "frontend/adj-view" # Backend packages - "backend" From 6a055dccdd0820b1d636534f2b6033ef10be93ee Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 19:06:16 +0200 Subject: [PATCH 2/5] fix(logging-view): capitalize title --- frontend/logging-view/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/logging-view/index.html b/frontend/logging-view/index.html index 8c45d9589..f33988ff0 100644 --- a/frontend/logging-view/index.html +++ b/frontend/logging-view/index.html @@ -4,7 +4,7 @@ - Hyperloop UPV logging view + Hyperloop UPV Logging View From e26e91760ff01e555f8e0b0505176c3fb01acbee Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 19:16:49 +0200 Subject: [PATCH 3/5] feat(ADJ-Viewer): select branch --- frontend/adj-view/config.ts | 7 ++ .../adj-view/src/components/AdjViewerPage.tsx | 89 ++++++++++++++++++- frontend/adj-view/src/hooks/useBranches.ts | 48 ++++++++++ 3 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 frontend/adj-view/config.ts create mode 100644 frontend/adj-view/src/hooks/useBranches.ts diff --git a/frontend/adj-view/config.ts b/frontend/adj-view/config.ts new file mode 100644 index 000000000..e583070f1 --- /dev/null +++ b/frontend/adj-view/config.ts @@ -0,0 +1,7 @@ +export const config = { + /** GitHub repository whose branches are offered when resolving a commit for the ADJ Viewer. */ + ADJ_GITHUB_REPO: "hyperloop-upv/adj", + + /** Timeout for fetching branches from GitHub API. */ + BRANCHES_FETCH_TIMEOUT: 5000, +} as const; diff --git a/frontend/adj-view/src/components/AdjViewerPage.tsx b/frontend/adj-view/src/components/AdjViewerPage.tsx index af793a46c..da5924e24 100644 --- a/frontend/adj-view/src/components/AdjViewerPage.tsx +++ b/frontend/adj-view/src/components/AdjViewerPage.tsx @@ -3,14 +3,22 @@ import { Badge, Button, + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, Input, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, } from "@workspace/ui/components"; -import { BookOpen, GitCommit, Loader2, SunMoon } from "@workspace/ui/icons"; +import { BookOpen, GitCommit, Loader2, RefreshCw, SunMoon } from "@workspace/ui/icons"; import { useCallback, useEffect, useState } from "react"; +import { config } from "../../config"; +import { useBranches } from "../hooks/useBranches"; import type { AdjArchive } from "../types/adj"; import { AdjViewerTabs, extractBoards } from "./AdjViewerTabs"; @@ -23,6 +31,15 @@ async function fetchAdjArchive(hash: string): Promise { return response.json(); } +async function resolveBranchToCommit(branch: string): Promise { + const response = await fetch( + `https://api.github.com/repos/${config.ADJ_GITHUB_REPO}/branches/${encodeURIComponent(branch)}`, + ); + if (!response.ok) throw new Error(`Branch lookup failed: ${response.status}`); + const data = await response.json(); + return data.commit.sha as string; +} + interface AdjViewerPageProps { isDark: boolean; onToggleTheme: () => void; @@ -35,6 +52,11 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + const { branches, isLoading: branchesLoading, error: branchesError, refetch: refetchBranches } = useBranches(true); + const [branchInput, setBranchInput] = useState(""); + 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); @@ -56,6 +78,20 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { const handleLoad = () => load(hashInput.trim()); + const handleBranchSelect = async (branch: string) => { + if (!branch) return; + try { + setResolvingBranch(true); + setError(null); + const sha = await resolveBranchToCommit(branch); + await load(sha); + } catch (err) { + setError(String(err)); + } finally { + setResolvingBranch(false); + } + }; + // If launched from logging-view's "View ADJ" shortcut, the main process // passes the session's commit hash through as a query param — load it // immediately instead of waiting for the user to type it in. @@ -87,6 +123,57 @@ export function AdjViewerPage({ isDark, onToggleTheme }: AdjViewerPageProps) { )}
+
+ { + setSelectedBranch(v); + setBranchInput(v ?? ""); + if (v) handleBranchSelect(v); + }} + > + { + setBranchInput(e.target.value); + setSelectedBranch(null); + }} + onKeyDown={(e) => { + if (e.key === "Enter") handleBranchSelect(branchInput.trim()); + }} + className="h-8 w-[10rem] text-xs" + /> + + No branches found + + {(item) => ( + + {item} + + )} + + + + +
+ + or + void; +} + +export const useBranches = (enabled: boolean): BranchesFetchState => { + const [branches, setBranches] = useState([]); + const [isLoading, startTransition] = useTransition(); + const [error, setError] = useState(false); + + const load = (signal: AbortSignal) => { + startTransition(async () => { + try { + setError(false); + const res = await fetch( + `https://api.github.com/repos/${config.ADJ_GITHUB_REPO}/branches?per_page=100`, + { signal: AbortSignal.any([signal, AbortSignal.timeout(config.BRANCHES_FETCH_TIMEOUT)]) }, + ); + const data = await res.json(); + setBranches(data.map((b: { name: string }) => b.name)); + } catch (err) { + if (err instanceof Error && err.name !== "AbortError") { + setError(true); + } + } + }); + }; + + const refetch = () => { + const controller = new AbortController(); + load(controller.signal); + }; + + useEffect(() => { + if (enabled) { + const controller = new AbortController(); + load(controller.signal); + return () => controller.abort(); + } + }, [enabled]); + + return { branches, isLoading, error, refetch }; +}; From c55b50a700ef59117860be85949d0986601caf25 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 19:34:38 +0200 Subject: [PATCH 4/5] feat(ADJ-view): enhance main page --- .../adj-view/src/components/AdjViewerTabs.tsx | 125 +++++++++++++----- 1 file changed, 90 insertions(+), 35 deletions(-) diff --git a/frontend/adj-view/src/components/AdjViewerTabs.tsx b/frontend/adj-view/src/components/AdjViewerTabs.tsx index eb75c642d..77e0de4c7 100644 --- a/frontend/adj-view/src/components/AdjViewerTabs.tsx +++ b/frontend/adj-view/src/components/AdjViewerTabs.tsx @@ -345,10 +345,10 @@ function BoardsTab({
- {/* Expanded: measurements list */} + {/* Expanded: measurements list — scrollable so long boards don't get cut off */} {expanded && board.measurements.length > 0 && ( -
- {board.measurements.slice(0, 30).map((m) => ( +
+ {board.measurements.map((m) => (
{m.name} {m.type && ( @@ -360,11 +360,6 @@ function BoardsTab({ {m.id}
))} - {board.measurements.length > 30 && ( -
- +{board.measurements.length - 30} more — use Measurements tab -
- )}
)}
@@ -381,13 +376,18 @@ function BoardsTab({ function MeasurementsTab({ boards, initialBoardFilter, + initialVariableFilter, + packetLabel, }: { boards: BoardMeta[]; initialBoardFilter: Set; + initialVariableFilter: Set; + packetLabel: string | null; }) { const [query, setQuery] = useState(""); const [activeBoards, setActiveBoards] = useState>(initialBoardFilter); const [activeTypes, setActiveTypes] = useState>(new Set()); + const [activeVariables, setActiveVariables] = useState>(initialVariableFilter); const [sortKey, setSortKey] = useState("name"); const [sortDir, setSortDir] = useState("asc"); const [expandedId, setExpandedId] = useState(null); @@ -415,6 +415,7 @@ function MeasurementsTab({ .filter((r) => { if (activeBoards.size > 0 && !activeBoards.has(r.board)) return false; if (activeTypes.size > 0 && !activeTypes.has(normalizeType(r.type ?? ""))) return false; + if (activeVariables.size > 0 && !activeVariables.has(r.id)) return false; if (q && !r.name.toLowerCase().includes(q) && !r.id.toLowerCase().includes(q) && !r.board.toLowerCase().includes(q)) return false; return true; }) @@ -424,7 +425,7 @@ function MeasurementsTab({ const vb = (sortKey === "units" ? (b.displayUnits ?? "") : sortKey === "type" ? (b.type ?? "") : String(b[sortKey as keyof typeof b] ?? "")).toLowerCase(); return va.localeCompare(vb) * dir; }); - }, [allRows, query, activeBoards, activeTypes, sortKey, sortDir]); + }, [allRows, query, activeBoards, activeTypes, activeVariables, 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; }); @@ -470,13 +471,30 @@ function MeasurementsTab({ ))} + {/* Packet-jump pill — separate from FilterPills since it's a single named + value (the packet that triggered the jump), not a board/type set. */} + {packetLabel && activeVariables.size > 0 && ( +
+ + Packet: {packetLabel} + + +
+ )} + {/* Active filter pills */} { setActiveBoards(new Set()); setActiveTypes(new Set()); }} + onClearAll={() => { setActiveBoards(new Set()); setActiveTypes(new Set()); setActiveVariables(new Set()); }} /> {/* Table */} @@ -568,7 +586,13 @@ function MeasurementsTab({ // ─── Packets tab ────────────────────────────────────────────────────────────── -function PacketsTab({ boards }: { boards: BoardMeta[] }) { +function PacketsTab({ + boards, + onJumpToMeasurements, +}: { + boards: BoardMeta[]; + onJumpToMeasurements: (boardName: string, packetName: string, variableIds: string[]) => void; +}) { const [query, setQuery] = useState(""); const [kind, setKind] = useState<"packets" | "orders">("packets"); const [activeBoards, setActiveBoards] = useState>(new Set()); @@ -647,30 +671,41 @@ function PacketsTab({ boards }: { boards: BoardMeta[] }) { />
- {filtered.map((r, i) => ( -
-
- - ID {r.id} - {r.period != null && ( - {r.period} {r.period_type} + {filtered.map((r, i) => { + const hasVariables = r.variables && r.variables.length > 0; + return ( +
onJumpToMeasurements(r.board, r.name, r.variables) : undefined} + className={cn( + "bg-muted/20 rounded-lg border px-3 py-2 transition-colors", + hasVariables && "cursor-pointer hover:bg-muted/30", )} - {r.socket && ( - {r.socket} + title={hasVariables ? "View measurements" : undefined} + > +
+ + ID {r.id} + {r.period != null && ( + {r.period} {r.period_type} + )} + {r.socket && ( + {r.socket} + )} + + + +
+ {hasVariables && ( +
+ {r.variables.map((v) => ( + {v} + ))} +
)} - - -
- {r.variables && r.variables.length > 0 && ( -
- {r.variables.map((v) => ( - {v} - ))} -
- )} -
- ))} + ); + })} {filtered.length === 0 && }
@@ -755,12 +790,26 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { // Lifted state for cross-tab navigation const [activeTab, setActiveTab] = useState("boards"); const [jumpBoardFilter, setJumpBoardFilter] = useState>(new Set()); + const [jumpVariableFilter, setJumpVariableFilter] = useState>(new Set()); + const [jumpPacketLabel, setJumpPacketLabel] = useState(null); const handleJumpToMeasurements = useCallback((boardName: string) => { setJumpBoardFilter(new Set([boardName])); + setJumpVariableFilter(new Set()); + setJumpPacketLabel(null); setActiveTab("measurements"); }, []); + const handleJumpToPacketMeasurements = useCallback( + (boardName: string, packetName: string, variableIds: string[]) => { + setJumpBoardFilter(new Set([boardName])); + setJumpVariableFilter(new Set(variableIds)); + setJumpPacketLabel(packetName); + setActiveTab("measurements"); + }, + [], + ); + return ( @@ -781,12 +830,18 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { - {/* key remounts MeasurementsTab on cross-tab jump so initialBoardFilter takes effect cleanly */} + {/* key remounts MeasurementsTab on cross-tab jump so initialBoardFilter/initialVariableFilter take effect cleanly */} - + - + From d9104475191be091ba58ab42699738e9d5b4d6b3 Mon Sep 17 00:00:00 2001 From: Javier Ribal del Rio Date: Sun, 2 Aug 2026 20:05:30 +0200 Subject: [PATCH 5/5] feat(adj-viewer): network menu --- .../adj-view/src/components/AdjViewerTabs.tsx | 14 +- .../adj-view/src/components/NetworkTab.tsx | 328 ++++++++++++++++++ frontend/adj-view/src/types/adj.ts | 14 +- 3 files changed, 352 insertions(+), 4 deletions(-) create mode 100644 frontend/adj-view/src/components/NetworkTab.tsx diff --git a/frontend/adj-view/src/components/AdjViewerTabs.tsx b/frontend/adj-view/src/components/AdjViewerTabs.tsx index 77e0de4c7..4987b78d9 100644 --- a/frontend/adj-view/src/components/AdjViewerTabs.tsx +++ b/frontend/adj-view/src/components/AdjViewerTabs.tsx @@ -16,13 +16,15 @@ import { ChevronUp, Cpu, ExternalLink, + Layers, Network, Search, Server, } 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 } from "../types/adj"; +import type { AdjArchive, AdjMeasurement, AdjPacket, AdjSocket } from "../types/adj"; +import { NetworkTab } from "./NetworkTab"; // ─── types ─────────────────────────────────────────────────────────────────── @@ -33,6 +35,7 @@ export type BoardMeta = { measurements: AdjMeasurement[]; packets: AdjPacket[]; orders: AdjPacket[]; + sockets: AdjSocket[]; }; type SortKey = "board" | "name" | "type" | "units" | "id"; @@ -52,6 +55,7 @@ export function extractBoards(adjData: AdjArchive): BoardMeta[] { measurements: (g[`${boardName}_measurements`] as AdjMeasurement[] | undefined) ?? [], packets: (g["packets"] as AdjPacket[] | undefined) ?? [], orders: (g["orders"] as AdjPacket[] | undefined) ?? [], + sockets: (g["sockets"] as AdjSocket[] | undefined) ?? [], }; }) .sort((a, b) => a.name.localeCompare(b.name)); @@ -820,7 +824,10 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { Measurements - Packets + Packets + + + Network General @@ -843,6 +850,9 @@ export const AdjViewerTabs = ({ adjData }: { adjData: AdjArchive }) => { + + + diff --git a/frontend/adj-view/src/components/NetworkTab.tsx b/frontend/adj-view/src/components/NetworkTab.tsx new file mode 100644 index 000000000..c2b21dc66 --- /dev/null +++ b/frontend/adj-view/src/components/NetworkTab.tsx @@ -0,0 +1,328 @@ +// Network tab: a hand-built SVG schema of the pod's network topology. +// Boards on the left, central addresses (general_info.addresses) on the right, +// arrows for each board's sockets, colored by protocol. No graph library in the +// 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 { BoardMeta } from "./AdjViewerTabs"; + +type Protocol = "TCP" | "UDP" | "OTHER"; + +// Hyperloop UPV's actual brand palette (no separate secondary brand color +// documented anywhere in the repo) — --primary is the brand orange, and +// --foreground pairs it with black/white, matching the logo assets under +// frontend-kit/ui/src/outreach. Both adapt automatically in light/dark mode. +const PROTOCOL_COLOR: Record = { + TCP: "var(--foreground)", // bidirectional + UDP: "var(--primary)", // unidirectional: board → backend + 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 { + id: string; + label: string; + ip: string; + boardId?: number; + badges: string[]; +} + +interface DiagramEdge { + key: string; + from: string; + to: string; + protocol: Protocol; + detail: string; +} + +interface NetworkGraph { + boardNodes: DiagramNode[]; + centralNodes: DiagramNode[]; + 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 { + const addresses = generalInfo.addresses ?? {}; + const centralMap = new Map(); + for (const [key, ip] of Object.entries(addresses)) { + centralMap.set(key, { id: key, label: key, ip, badges: [] }); + } + + // 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])); + + // 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. + const backendKey = "backend" in addresses ? "backend" : Object.keys(addresses).length === 1 ? Object.keys(addresses)[0] : null; + + const boardNodes: DiagramNode[] = []; + const edges: DiagramEdge[] = []; + + for (const board of boards) { + const badges: string[] = []; + for (const socket of board.sockets) { + const protocol = protocolFromSocketType(socket.type); + 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; + } + edges.push({ + key: `${board.name}-${socket.name}-out`, + from: board.name, + to, + protocol, + detail: `${socket.name} · :${socket.port}`, + }); + } else if (backendKey) { + edges.push({ + key: `${board.name}-${socket.name}-in`, + from: backendKey, + to: board.name, + protocol, + detail: `${socket.name} · :${socket.port}`, + }); + } else { + badges.push(`listens :${socket.port}`); + } + } + boardNodes.push({ id: board.name, label: board.name, ip: board.ip, boardId: board.id, badges }); + } + + return { boardNodes, centralNodes: [...centralMap.values()], edges }; +} + +// ─── layout ────────────────────────────────────────────────────────────────── + +const NODE_W = 200; +const NODE_H = 72; +const ROW_H = 116; +const PADDING = 32; +const DIAGRAM_W = 680; +// Vertical margin kept clear at the top/bottom of a node's connecting edge +// when fanning out multiple anchor points along it. +const ANCHOR_MARGIN = 16; + +function columnCenterY(index: number, count: number, totalRows: number): number { + const totalHeight = totalRows * ROW_H; + const colHeight = count * ROW_H; + const offset = (totalHeight - colHeight) / 2; + return PADDING + offset + index * ROW_H + ROW_H / 2; +} + +// ─── rendering ─────────────────────────────────────────────────────────────── + +function NodeBox({ node, x, y, central }: { node: DiagramNode; x: number; y: number; central: boolean }) { + return ( + + + + {node.label} + {node.boardId != null ? ` · ID ${node.boardId}` : ""} + + + {node.ip} + + {node.badges.length > 0 && ( + + {node.badges.join(" · ")} + + )} + + ); +} + +function EdgePath({ edge, from, to }: { edge: DiagramEdge; from: { x: number; y: number }; to: { x: number; y: number } }) { + // Board-to-board edges anchor on the same side (both x's equal), which would + // collapse the curve into a flat line hugging the column edge — bow it out + // toward the middle of the diagram instead so it reads as a distinct loop. + const sameSide = from.x === to.x; + const midX = sameSide ? from.x + 70 : (from.x + to.x) / 2; + const color = PROTOCOL_COLOR[edge.protocol]; + const markerId = `network-arrow-${edge.protocol.toLowerCase()}`; + return ( + + {`${edge.protocol} · ${edge.detail}`} + + ); +} + +function ArrowMarker({ protocol }: { protocol: Protocol }) { + return ( + + + + ); +} + +function Legend() { + return ( +
+ + + TCP ↔ + + + + UDP → backend + +
+ ); +} + +export function NetworkTab({ boards, generalInfo }: { boards: BoardMeta[]; generalInfo: AdjArchive["general_info"] }) { + const graph = useMemo(() => buildNetworkGraph(boards, generalInfo), [boards, generalInfo]); + + const totalRows = Math.max(graph.boardNodes.length, graph.centralNodes.length, 1); + const height = PADDING * 2 + totalRows * ROW_H; + const boardX = PADDING; + const centralX = DIAGRAM_W - PADDING - NODE_W; + + const positions = new Map(); + graph.boardNodes.forEach((node, i) => { + positions.set(node.id, { x: boardX, y: columnCenterY(i, graph.boardNodes.length, totalRows), side: "left" }); + }); + graph.centralNodes.forEach((node, i) => { + positions.set(node.id, { x: centralX, y: columnCenterY(i, graph.centralNodes.length, totalRows), side: "right" }); + }); + + // Every edge touching a node lands on that node's single connecting edge + // (boards always face right toward the central column; central nodes always + // face left toward boards) — fan those points out across the node's height + // instead of collapsing them all onto its center, which is what made + // multiple edges overlap into an unreadable smudge. + const edgesPerNode = new Map(); + const registerEdge = (nodeId: string, edgeKey: string) => { + const list = edgesPerNode.get(nodeId) ?? []; + list.push(edgeKey); + edgesPerNode.set(nodeId, list); + }; + for (const edge of graph.edges) { + registerEdge(edge.from, edge.key); + registerEdge(edge.to, edge.key); + } + + const anchor = (nodeId: string, edgeKey: string, facing: "left" | "right") => { + const pos = positions.get(nodeId); + if (!pos) return { x: 0, y: 0 }; + const x = facing === "right" ? pos.x + NODE_W : pos.x; + const siblings = edgesPerNode.get(nodeId) ?? [edgeKey]; + const count = siblings.length; + if (count <= 1) return { x, y: pos.y }; + const idx = siblings.indexOf(edgeKey); + const usable = NODE_H - ANCHOR_MARGIN * 2; + const y = pos.y - usable / 2 + (usable * idx) / (count - 1); + return { x, y }; + }; + + if (graph.boardNodes.length === 0) { + return ( +
+ No boards to show. +
+ ); + } + + return ( +
+ +
+ + + + + + + + {graph.edges.map((edge) => { + // Boards always face right toward the central column, central + // nodes always face left toward boards — except board-to-board + // edges (direct socket traffic between two boards), where both + // ends face right since both nodes sit in the left column. + const fromFacing = positions.get(edge.from)?.side === "left" ? "right" : "left"; + const toFacing = positions.get(edge.to)?.side === "left" ? "right" : "left"; + const from = anchor(edge.from, edge.key, fromFacing); + const to = anchor(edge.to, edge.key, toFacing); + return ; + })} + + {graph.boardNodes.map((node) => ( + + ))} + {graph.centralNodes.map((node) => ( + + ))} + +
+
+ ); +} diff --git a/frontend/adj-view/src/types/adj.ts b/frontend/adj-view/src/types/adj.ts index 1af0c761c..b8a1c40f0 100644 --- a/frontend/adj-view/src/types/adj.ts +++ b/frontend/adj-view/src/types/adj.ts @@ -29,10 +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). +export interface AdjSocket { + type: string; + name: string; + port: number; + remote_ip?: string; +} + // boards[boardName] is a nested group, not a flat object. // Keys: boardName (AdjBoardInfo), `${boardName}_measurements` (AdjMeasurement[]), -// "packets", "packets_old", "orders", "orders_old" (AdjPacket[]). -export type AdjBoardGroup = Record; +// "packets", "packets_old", "orders", "orders_old" (AdjPacket[]), "sockets" (AdjSocket[]). +export type AdjBoardGroup = Record; export interface AdjArchive { boards: Record;