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/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/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..da5924e24
--- /dev/null
+++ b/frontend/adj-view/src/components/AdjViewerPage.tsx
@@ -0,0 +1,219 @@
+// 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,
+ Combobox,
+ ComboboxContent,
+ ComboboxEmpty,
+ ComboboxInput,
+ ComboboxItem,
+ ComboboxList,
+ Input,
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@workspace/ui/components";
+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";
+
+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();
+}
+
+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;
+}
+
+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 { 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);
+
+ 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());
+
+ 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.
+ 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
+
+ )}
+
+
+
+ {
+ 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
+
+
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 80%
rename from frontend/logging-view/src/components/sidebar/AdjViewerDialog.tsx
rename to frontend/adj-view/src/components/AdjViewerTabs.tsx
index 45d739c87..4987b78d9 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,30 +11,31 @@ import {
} from "@workspace/ui/components";
import {
Activity,
- BookOpen,
ChevronDown,
ChevronRight,
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 { useStore } from "../../store/store";
-import type { AdjArchive, AdjMeasurement, AdjPacket } from "../../types/session";
+import type { AdjArchive, AdjMeasurement, AdjPacket, AdjSocket } from "../types/adj";
+import { NetworkTab } from "./NetworkTab";
// ─── types ───────────────────────────────────────────────────────────────────
-type BoardMeta = {
+export type BoardMeta = {
name: string;
id: number;
ip: string;
measurements: AdjMeasurement[];
packets: AdjPacket[];
orders: AdjPacket[];
+ sockets: AdjSocket[];
};
type SortKey = "board" | "name" | "type" | "units" | "id";
@@ -44,7 +43,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;
@@ -56,6 +55,7 @@ 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));
@@ -349,10 +349,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 && (
@@ -364,11 +364,6 @@ function BoardsTab({
{m.id}
))}
- {board.measurements.length > 30 && (
-
- +{board.measurements.length - 30} more — use Measurements tab
-
- )}
)}
@@ -385,13 +380,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);
@@ -419,6 +419,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;
})
@@ -428,7 +429,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; });
@@ -474,13 +475,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 */}
@@ -572,7 +590,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());
@@ -651,30 +675,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 &&
}
@@ -751,91 +786,76 @@ 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");
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 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 handleJumpToPacketMeasurements = useCallback(
+ (boardName: string, packetName: string, variableIds: string[]) => {
+ setJumpBoardFilter(new Set([boardName]));
+ setJumpVariableFilter(new Set(variableIds));
+ setJumpPacketLabel(packetName);
+ setActiveTab("measurements");
+ },
+ [],
+ );
return (
-
+
+
+
+ Boards
+
+
+ Measurements
+
+
+ Packets
+
+
+ Network
+
+
+ General
+
+
+
+
+
+
+ {/* key remounts MeasurementsTab on cross-tab jump so initialBoardFilter/initialVariableFilter take effect cleanly */}
+
+
+
+
+
+
+
+
+
+
+
+
+
);
};
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 (
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/adj-view/src/hooks/useBranches.ts b/frontend/adj-view/src/hooks/useBranches.ts
new file mode 100644
index 000000000..60b78fd4c
--- /dev/null
+++ b/frontend/adj-view/src/hooks/useBranches.ts
@@ -0,0 +1,48 @@
+import { useEffect, useState, useTransition } from "react";
+import { config } from "../../config";
+
+export interface BranchesFetchState {
+ branches: string[];
+ isLoading: boolean;
+ error: boolean;
+ refetch: () => 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 };
+};
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..b8a1c40f0
--- /dev/null
+++ b/frontend/adj-view/src/types/adj.ts
@@ -0,0 +1,56 @@
+// 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[];
+}
+
+// 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[]), "sockets" (AdjSocket[]).
+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/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
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"