Adding a page to the web UI#
A new screen in the Wheelhouse UI is four edits: a component under ui/src/pages/, a
lazy import and a route in App.tsx, an entry in nav.ts, and whatever the typed API
client is missing. There is no scaffolding step and no registry to update beyond those.
What takes the thought is not the wiring, it is the four rules every page follows — the
ones that make the product what it is — and knowing which of the two dozen existing
components you should be using rather than writing.
The four edits#
1. The component#
ui/src/pages/<Name>.tsx, default export, one component. The shape almost every page
uses:
import { api } from '../lib/api'
import { useApi } from '../lib/hooks'
import { useStaging } from '../lib/staging'
import { ErrorNote, Loading, PageHeader, Panel } from '../components/ui'
export default function Ntp() {
// `revision` bumps after every successful commit, so the page refetches
// when the Commit Bar lands a change made anywhere in the product.
const { revision } = useStaging()
const { data, error, refresh } = useApi(() => api.ntp(), [revision], 15_000)
if (error) return <ErrorNote error={error} onRetry={refresh} />
if (!data) return <Loading what="time synchronisation" />
return (
<div className="space-y-4">
<PageHeader title="Time" path="service ntp" subtitle="Servers and who may ask this router for the time." />
<Panel title="Servers">{/* … */}</Panel>
</div>
)
}useApi(fetcher, deps, pollMs) is fetch-on-mount with a manual refresh and optional
polling. It carries a sequence number so a slow response cannot overwrite a newer one — a
500-line log fetch once landed on top of the 1000-line fetch that followed it.
2. The route#
In ui/src/App.tsx, a lazy import beside the others and a
<Route> in the table:
const Ntp = lazy(() => import('./pages/Ntp'))
// …
<Route path="/ntp" element={<Ntp />} />Lazy, always. Every route page is code-split; only the login screen and the shell stay in the main chunk.
An admin-only page is guarded in the table as well as by the agent —
{canAdmin && <Route path="/users" element={<Users />} />} — and the comment beside it
says the quiet part: hiding a route is only UX, the agent enforces the same gate.
3. The nav entry#
ui/src/components/nav.ts holds one array. It feeds the
left navigation and the ⌘K palette, so an entry added here appears in both:
{ to: '/ntp', label: 'Time', group: 'Services', icon: 'time' },| Field | Effect |
|---|---|
group | The heading it sits under, one of NAV_GROUPS. Items are grouped in array order, so keep a group contiguous |
icon | A name from ui/src/components/icons.tsx. A new page gets a new one there, drawn in that file's style: a line drawing on the 24px grid, 1.5px strokes, currentColor. Never a text glyph |
requires: 'admin' | Rendered only for an admin principal |
feature: '<catalogue id>' | Hidden unless that feature module is installed — how WireGuard and IDS work |
licenseFeature: '<name>' | Hidden unless the licence's plan carries it — how Fleet works |
A licenseFeature entry is hidden rather than shown disabled: a page whose whole
content is "buy the plan that turns this on" would sit in the nav of every single-router
install. Discovery of a licensed feature belongs on the Licence page.
navGroups() treats a licensedFeatures of null as "the licence has not answered
yet", so a gated entry does not blink out of the nav on every reload before settling.
4. The API client#
ui/src/lib/api.ts is hand-written; there is no generator,
though GET /api/openapi.json describes what you are writing against. Add the wire types
near the other types and a function to the api object:
export interface NtpState { servers: string[]; allow: string[] }
// on the api object:
ntp: () => request<NtpState>('/api/ntp'),request() sets the content type, attaches X-Wheelhouse-CSRF on anything that is not
GET/HEAD/OPTIONS, sends credentials: 'same-origin', and routes a 401 on
anything but the login call itself to the global handler that drops the app back to the
sign-in screen.
The four rules every page follows#
From docs/ui.md. A page that breaks one of these is a page that has
stopped being Wheelhouse. How an editor is built is
docs/editor-pattern.md, and every word it shows follows
docs/ui-copy.md: no commands, configuration paths or console
instructions outside Advanced mode. That has been the rule since 0.9.1, when the owner
retired "every editor shows its commands".
1. Nothing applies itself#
A page's editors call stage(...). Only the Commit Bar applies anything. An editor is a
form: it keeps a draft, checks every value where it is typed, keeps its button disabled
while anything is wrong, and stages exactly the difference. The Commit Bar lists what is
pending as sentences written by a describer in lib/describe, so a page that stages
something new registers a describer for it. The commands are Advanced mode's: CliBlock
renders nothing without it.
const { stage } = useStaging()
const ops = [{ op: 'set', path: ['service', 'ntp', 'server', name] }] as const
<CliBlock label="Commands" text={ops.map(renderOp).join('\n')} />
<GatedButton allowed={canWrite} disabled={invalid} onClick={() => stage(...ops)}>Add time server</GatedButton>renderOp in ui/src/lib/format.ts spells a path the way
the router's CLI would, quoting a value that contains a space or a quote. Its comment
records the constraint: it must agree with the agent's own cliWord in
agent/staging.go, because a preview in Advanced mode that is not
the command the router ran would be a lie in the one place the product promises never to
tell one.
2. State and intent are visually distinct#
Configured values render as plain fields. Live values carry a LiveDot or a counter
column. Where the two disagree, the page shows a DriftBadge naming the disagreement —
an interface the kernel reports that the configuration does not declare, a qdisc that is
not the policy's, a static route declared but absent from the forwarding table.
3. Empty states lead somewhere#
A blank page says what is missing and offers the button that adds it — No port
forwards, Add port forward — rather than saying "no data". EmptyState takes a
title, at most one sentence of body, the action and the commands; the commands appear in
Advanced mode only.
4. Side panels, not modals#
Editing an object keeps its table readable behind the panel. SidePanel closes on Esc.
Use what is already there#
ui/src/components/ui.tsx exports the whole vocabulary.
The ones you will reach for:
| Component | For |
|---|---|
PageHeader | The title, the configuration path this page owns (shown in Advanced mode), a subtitle, actions |
Panel | A titled surface with optional meta and actions |
DataTable | Sortable, focusable rows; Enter opens the row's side panel |
SidePanel, PanelSection, Field | The editor pattern |
CliBlock | Commands, with a copy button — rendered in Advanced mode only, heading in label |
CodeBlock | Text the reader must copy whatever the mode: a one-time code, a new token, a public key |
GatedButton | A write action the principal may not perform, disabled with the reason rather than hidden |
Badge, LiveDot, DriftBadge | State, liveness, disagreement |
StatTile, KeyValues, Mono | Numbers and values that came off the router |
EmptyState, Loading, ErrorNote | The three states that are not the happy path |
Segmented, ViewToggle, FilterInput, Checkbox, Select, TextInput, TextArea | Controls |
PageHeader's path is not decoration. Every page names the configuration subtree it
owns, and Advanced mode shows it beside the title, so an operator who works in the router's
language can find the page from a path and the path from a page. Without Advanced mode it
is not shown.
Tabs, not more nav entries#
Things that belong together share a page with tabs rather than earning a second nav entry.
The ?tab= query keeps deep links working — /ha?tab=vrrp — and an old address
redirects rather than 404ing:
<Route path="/wan" element={<Navigate to="/ha" replace />} />Multi-WAN moved inside High availability and /wan still lands.
Before you push#
Give the page a test: ui/src/pages/__tests__/<Name>.test.tsx, rendered with
renderPage against answers captured from a router, declaring every endpoint the page
calls and asserting what each editor stages. The Tests section of
docs/ui.md says how one is written. CI's UI step runs npm test and
npm run test:qr between tsc --noEmit and vite build, so a failing page test stops
the job. The tests run in jsdom, which has no layout and no live socket, so the things
they cannot catch are yours:
- Drive the page in a browser against a live router, not a mock.
- Save something, read the sentence the Commit Bar gives it, and apply it.
- Look at the page with Advanced mode off, then on: no command or configuration path should show without it.
- Check both themes. A raw Tailwind colour class renders correctly in one and wrongly in the other, silently.
- Check the page as a
viewer: every write control should be visibly disabled with a reason, not missing. - Check the empty state, by pointing at a router that has none of what the page shows.
See also#
- Building the web UI — the build, the dev proxy, the theming tokens.
- Adding an endpoint — the other half, when the page needs new data.
- Architecture — what the agent is doing behind
api.ntp(). - Adding an app to the catalogue — how
feature:gating is declared. - Pending changes · The Commit Bar · Advanced mode and the router's commands — rule 1, from the operator's side.
- State, intent and drift — rule 2, and every place the product compares the two.
- Apps — what an existing page looks like when it is documented rather than written.