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: '⏲' },| Field | Effect |
|---|---|
group | The heading it sits under. Items are grouped in array order, so keep a group contiguous |
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 |
The comment on licenseFeature records the reasoning worth repeating: a page whose whole
content is "buy the plan that turns this on" is an advert in the nav of every
single-router install, and discovery belongs on the Licence page instead.
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 and
no OpenAPI document. 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.
1. Nothing applies itself#
A page's editors call stage(...). Only the Commit Bar commits. Every editor renders the
exact set and delete lines it will run, with a copy button, before anything is
staged:
const { stage } = useStaging()
const ops = [{ op: 'set', path: ['service', 'ntp', 'server', name] }] as const
<CliBlock text={ops.map(renderOp).join('\n')} />
<GatedButton allowed={canWrite} onClick={() => stage(...ops)}>Stage</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 that is not the command
the router ran is 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 teach#
A blank page names the concepts behind it and lists the CLI commands that would populate
it. EmptyState takes a title, a body and the commands; "no data" is not an acceptable
empty state.
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, 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 |
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 beside its title, so the tree is never more than a glance away, and so a CLI
operator can find the page from a path and a UI operator can find the path from a page.
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#
There are no UI tests. What CI checks is tsc --noEmit and vite build, so the things
it cannot catch are yours:
- Drive the page in a browser against a live router, not a mock.
- Stage something, look at the Commit Bar's rendering of it, and commit 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. - Staging: the working set · The Commit Bar · Every click shows its 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.
Checked against#
ui/src/App.tsx ·
ui/src/components/nav.ts ·
ui/src/components/ui.tsx ·
ui/src/lib/api.ts ·
ui/src/lib/hooks.ts ·
ui/src/lib/staging.tsx ·
ui/src/lib/format.ts ·
ui/src/lib/session.tsx ·
ui/src/pages/Daemons.tsx ·
agent/staging.go ·
docs/ui.md