Wheelhouse docs

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:

tsx
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:

tsx
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:

ts
{ to: '/ntp', label: 'Time', group: 'Services', icon: '⏲' },
FieldEffect
groupThe 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:

ts
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:

tsx
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:

ComponentFor
PageHeaderThe title, the configuration path this page owns, a subtitle, actions
PanelA titled surface with optional meta and actions
DataTableSortable, focusable rows; Enter opens the row's side panel
SidePanel, PanelSection, FieldThe editor pattern
CliBlockCommands, with a copy button
GatedButtonA write action the principal may not perform, disabled with the reason rather than hidden
Badge, LiveDot, DriftBadgeState, liveness, disagreement
StatTile, KeyValues, MonoNumbers and values that came off the router
EmptyState, Loading, ErrorNoteThe three states that are not the happy path
Segmented, ViewToggle, FilterInput, Checkbox, Select, TextInput, TextAreaControls

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:

tsx
<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#

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

Updated 2026-09-02 development ui react pages