Building the web UI#
ui/ is a React and TypeScript single-page application built by Vite. It has three
runtime dependencies — React, React DOM and React Router — and no state library, no
component library and no data-fetching library. It talks to the agent over the same
public HTTP API a script would use, holds no credential of its own, and is built to a
directory the agent serves. Building it is npm ci and vite build; running it in
development is a dev server that proxies the agent so the application is same-origin in
both places.
The commands#
cd ui
npm install # or npm ci, which CI uses
WHEELHOUSE_AGENT=http://127.0.0.1:8090 npm run dev # http://localhost:3000
npm run build # tsc, then vite build, into ui/dist
npm run preview # serve the built bundle
npm run shots # screenshots against a running agentnpm run build is tsc && vite build — the type check is part of the build, so a type
error is a build failure and not a warning. CI runs the two separately so it can report
which one failed:
npm ci --no-audit --no-fund
npx tsc --noEmit -p .
npx vite build --logLevel warnThe dev server proxy#
ui/vite.config.ts proxies three paths at whatever
WHEELHOUSE_AGENT names, with the WebSocket upgrade enabled on the stream:
| Path | Proxied |
|---|---|
/api | Yes |
/health | Yes |
/api/stream | Yes, with ws: true |
The default target is http://10.0.0.50:8090, which is the development bench, so set
WHEELHOUSE_AGENT unless you are on it. Point it at https://<router>:8443 to drive an
appliance from a local dev server.
Same-origin is not a convenience here. The browser holds a session cookie, and mutations
echo the session's CSRF token in X-Wheelhouse-CSRF — a cross-origin dev setup would
diverge from production exactly where authentication is decided.
What the build produces#
ui/dist/, containing index.html, hashed assets under assets/, the two fonts under
fonts/ with their OFL texts, the icons, site.webmanifest and theme-boot.js. That
directory is what packaging/build-deb.py copies to
/usr/share/wheelhouse/ui, and what --ui-dir points at.
Every route page is code-split. From ui/src/App.tsx:
const Dashboard = lazy(() => import('./pages/Dashboard'))The comment above that block records why: statically importing all of them put the whole
product in one 535 KB file that a browser had to parse before it could draw the
Dashboard, on a box whose CPU is busy routing. The login screen and the shell stay in the
main chunk because they are the critical path. One Suspense boundary covers the whole
route table, and Loading paints nothing for its first 400 ms, so a navigation off a
warm agent looks instant rather than flashing a spinner.
The two fonts#
Inter and JetBrains Mono are shipped with the product, as variable woff2 files under
ui/public/fonts/ with their SIL Open Font License texts beside them. The comment in
ui/src/index.css gives the reason: a router has no guaranteed
egress, so a CDN link would mean the identity collapses to a system fallback on exactly
the boxes that need it most.
Theming#
Colours are CSS custom properties defined once per theme in
ui/src/index.css and exposed to Tailwind as semantic names in
ui/tailwind.config.js:
colors: {
bg: 'rgb(var(--bg) / <alpha-value>)',
surface: 'rgb(var(--surface) / <alpha-value>)',
ink: 'rgb(var(--ink) / <alpha-value>)',
accent: 'rgb(var(--accent) / <alpha-value>)',
// …
}Components use bg-surface, text-muted and border-line, never a raw palette class,
so one class set renders in both themes. darkMode: 'class', and
ui/public/theme-boot.js runs before first paint so the page does not flash the wrong
theme.
Screenshots#
ui/scripts/screenshot.mjs drives the built UI in a
real browser against a running agent, captures a full-page screenshot per route and
collects the console errors:
npm run build
ip netns exec vlab npm run shots -- '/,/nat,/firewall'It needs playwright-core (a dev dependency) and a Chromium build, and it prints the
first fourteen lines of each page's text so a page that rendered empty is obvious. The
vlab network namespace is the development bench's, and it is the only place the router,
and therefore the agent, is reachable from — see wheelhouse-infra for that.
What is missing, and what that costs you#
ui/ has no automated tests. CI runs tsc --noEmit and vite build and nothing
else. docs/ui.md states the consequence plainly:
A change to
lib/api.tsorlib/staging.tsxcan break every editor in the product with CI still green; test by hand until that is fixed.
Treat those two files, and components/CommitBar.tsx, as the blast radius. A change to
any of them wants a browser session against a live router before it is pushed.
See also#
- Adding a page to the web UI — the four files a new page touches.
- A development environment — getting an agent for the dev server to proxy.
- Building the package — where
ui/distends up. - The test suites — including the ones the UI does not have.
- The telemetry stream — what the proxied WebSocket carries, and the same-origin rule above from the protocol side.
- Every click shows its commands — the promise the bundle exists to keep.
- Third-party licences — the two typefaces and every package bundled into the build.
Checked against#
ui/package.json ·
ui/vite.config.ts ·
ui/tsconfig.json ·
ui/tailwind.config.js ·
ui/src/App.tsx ·
ui/src/index.css ·
ui/scripts/screenshot.mjs ·
ui/public/fonts/README.md ·
agent/main.go ·
packaging/build-deb.py ·
docs/ui.md ·
.forgejo/workflows/ci.yml