Web UI Development Guide¶
This document covers local development of the sqi-server web UI: prerequisites,
the dev-server workflow against a live server and worker, the proxy
configuration, and how to add a new route, an API query hook, and a WebSocket
subscription. For the Go server/worker side, see
development.md; for the production build and embedding, see
web-build.md.
The UI source lives in web/; see web/README.md
for the directory layout.
Prerequisites¶
| Tool | Purpose | Install |
|---|---|---|
| Node.js ≥ 24 with npm ≥ 11 | Build and develop the web UI | nodejs.org or nvm use |
Go ≥ 1.26 (the go directive in go.mod pins 1.26.3) |
Build and run sqi-server / sqi-worker to develop against |
go.dev/dl |
The Node and npm minimums are pinned in .nvmrc (repo root) and the engines
field of web/package.json. From the repo root:
nvm use # selects the Node version from .nvmrc
cd web
npm install # install dependencies (run once, and after dependency changes)
Local development workflow¶
The Vite dev server provides hot-module reload and proxies API traffic to a
local sqi-server, so you edit UI code and see changes instantly while reading
and writing real data.
A complete loop runs three processes. Run each in its own terminal from the repo root unless noted.
# Terminal 1 — sqi-server (SQLite + embedded NATS, defaults to :8080)
make run
# or, after `make build`: ./bin/sqi-server serve
# Terminal 2 — a worker, so submitted jobs actually run
./bin/sqi-worker --server http://localhost:8080
# (build it first with `make build` if needed)
# Terminal 3 — the Vite dev server (from web/)
cd web && npm run dev
Open the URL Vite prints (default http://localhost:5173). The dashboard should
show live worker data; submitting a job from /submit runs it on the worker and
the job list updates in real time over WebSocket.
You do not need a worker just to develop most views — the server alone
serves the job/worker lists and the dashboard. Add a worker when you want jobs
to progress through running → succeeded and to exercise the live log viewer.
Quick checks while developing:
Command (run in web/) |
Purpose |
|---|---|
npm run dev |
Dev server with HMR + proxy |
npm run typecheck |
tsc -b, no emit — catch type errors early |
npm run lint |
ESLint (--max-warnings 0) |
npm run test:watch |
Vitest in watch mode |
Before considering a change done, run the full gate (matches CI):
npm run format:check && npm run typecheck && npm run lint && npm run test:coverage
Proxy configuration¶
The dev server never talks to the server's origin directly — it proxies, which
avoids CORS entirely and lets the same relative /api/... paths the production
bundle uses work in development. The relevant block in web/vite.config.ts:
server: {
proxy: {
// Forward all /api requests (REST + WebSocket) to a local sqi-server.
// ws: true enables WebSocket upgrade proxying, covering /api/v1/ws.
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
ws: true,
},
},
}
- Every request whose path starts with
/apiis forwarded totarget. ws: trueproxies the WebSocket upgrade, so the live-update connection at/api/v1/wsworks through the dev server.- If your server listens elsewhere (e.g.
SQI_HTTP_ADDR=127.0.0.1:9090), changetargetto match.
Because the client code builds API URLs from import.meta.env.VITE_API_BASE_URL
(defaulting to / — see src/api/client.ts), production and development use the
same relative paths; only the proxy differs.
Adding a new route¶
Routes are declared in web/src/routes.tsx using React
Router.
- Create the page component under
src/pages/, e.g.Presets.tsx, default- exporting a React component. Co-locate styles asPresets.module.cssand a test asPresets.test.tsx. - Register it in
routes.tsx:
import Presets from '@/pages/Presets'
// …
<Route path="/presets" element={<Presets />} />
Keep the catch-all <Route path="*" element={<NotFound />} /> last.
The product management pages live at /products (list), /products/new and
/products/:name/edit (the create/edit form), and /products/:name (read-only
detail). Built-in products are read-only and offer "Duplicate to custom".
The product submission flow uses three routes:
| Route | Component | Description |
|---|---|---|
/submit |
ProductPicker |
Product picker — lists all catalog products for the user to choose from |
/submit/product/:name |
ProductSubmit |
Product submission form — fetches parameters and renders a dynamic form |
/submit/raw |
Submit |
Raw OpenJD editor for direct template submission without the catalog |
The preset library (under the Admin hub) uses two routes:
| Route | Component | Description |
|---|---|---|
/presets |
PresetLibrary |
Browse all presets from the index with per-preset status and a Refresh button |
/presets/:name |
PresetDetail |
Preview a preset's metadata and template (read-only); Install / Update / Reinstall button |
- Surface it in navigation:
- Operational views (dashboard, submit, jobs, workers) are top-level
entries in
src/components/layout/Sidebar.tsx— add a<NavLink>so the active link highlights based on the URL. - Admin / management views (farms, queues, usage pools, storage, compute
locations, products, preset library, server log) are not in the sidebar;
they live on the Admin hub (
/admin,src/pages/Admin.tsx). Add an entry to itsADMIN_LINKSregistry (label,description,to) and the card grid renders it. The sidebar's only management entry is Admin itself; the server log is its own route,/server-log. The sidebar's fixed set of top-level entries is thePHASE1_NAVarray inSidebar.tsx(Dashboard, Submit, Jobs, Workers, Admin); there are no disabled or "coming soon" placeholder entries.
Use the @/ path alias (configured in both vite.config.ts and
tsconfig.app.json) for imports instead of relative ../../ paths.
Adding a new API query hook¶
The REST layer lives in web/src/api/. A read endpoint is added in three steps:
the wire type, a raw fetch function, and a TanStack Query hook.
- Type the response in
src/api/types.ts, matching the server's JSON wire format (verify againstGET /api/v1/openapi.yaml):
/** Wire shape returned by GET /api/v1/presets. */
export interface Preset {
id: string
name: string
// …
}
- Add a raw fetch function and a query-key entry in
src/api/queries.ts. The key factory enables prefix-based invalidation:
export const queryKeys = {
// …
presets: { all: ['presets'] as const },
}
export function fetchListPresets(): Promise<ListResponse<Preset>> {
return apiFetch('/presets')
}
apiFetch<T> (in src/api/client.ts) sets the JSON headers, prefixes
/api/v1, and throws a typed ApiError on non-2xx responses.
- Wrap it in a
useQueryhook:
/** List all presets. */
export function useListPresets() {
return useQuery({
queryKey: queryKeys.presets.all,
queryFn: fetchListPresets,
})
}
For a write endpoint, add a useMutation hook in src/api/mutations.ts that
calls apiFetch with the appropriate method and invalidates the affected query
keys in onSuccess (see useCancelJob / useRetryJob / useSubmitJob for the pattern). Add
unit tests next to the code, mirroring client.test.ts, queries.test.ts, and
mutations.test.tsx.
Adding a WebSocket subscription¶
Live updates flow over a single WebSocket managed by src/ws/. To react to push
messages in a component:
- Confirm (or add) a typed payload in
src/ws/events.tswith a matching type guard (isJobEvent,isTaskEvent,isWorkerEventare the existing ones). - Subscribe with the
useWebSocket(subject, handler)hook fromsrc/ws/context.tsx. It subscribes on mount and unsubscribes automatically on unmount (or whensubjectchanges), and stabilises the handler so inline arrow functions are safe:
useWebSocket('workers', (payload) => {
if (isWorkerEvent(payload)) {
// update local state / patch the query cache in place
}
})
Subjects mirror the server's hub (e.g. jobs, jobs/{jobId}/tasks, workers,
tasks/{taskId}/logs). The provider lives at the app root
(WebSocketProvider in main.tsx), and ConnectionStatusBadge surfaces the
connection state in the sidebar so operators can tell whether live updates are
flowing.
JobList exposes per-row and bulk retry affordances: each row for a job with
failed or canceled tasks shows a Retry button (backed by POST
/api/v1/jobs/{id}/retry via the useRetryJob mutation); the bulk toolbar
shows a Retry M button when retryable jobs are selected alongside (or
instead of) cancelable ones. The select-all header includes retryable jobs in
its scope.
The jobs subject carries a synthetic status: "removed" event when a job is
hard-deleted — either by a per-row or bulk Delete action in JobList, or
automatically by the server's retention sweep. JobList handles this by
removing the row from the displayed list immediately on receipt. Bulk delete
shows a confirmation dialog before issuing the requests. When adding components
that display job data from the jobs subject, handle the removed status to
avoid stale rows lingering in the UI.
JobDetail also surfaces why tasks failed: a job-level failure banner
(built from the JobDetail.failure_summary field — omitted until at least one
task has failed) above the task list, and a per-task reason string next to
each failed row's status (Task.failure_reason). Neither needs a dedicated
WebSocket message type — both are ordinary REST fields on the existing
job/task responses, refreshed the same way the rest of the page is: JobDetail
already invalidates the job-detail and task-list queries on jobs and
jobs/{jobId}/tasks WebSocket events, so the banner and reason strings update
live along with everything else. See
docs/observability.md for what the
reason strings mean and where the underlying data comes from.
Each task row is also independently expandable to a full attempt
timeline: a disclosure button toggles a detail row rendering every attempt
(number, status, worker, exit code, the per-attempt message, and
started→ended duration) via GET /api/v1/tasks/{id}/attempts. Unlike the
failure banner and reason string above, this data is fetched lazily — the
useTaskAttempts(id, { enabled }) hook (src/api/queries.ts) only queries
once a row is expanded, so collapsed rows cost nothing. The jobs/{jobId}/tasks
WebSocket handler invalidates queryKeys.tasks.attempts(payload.task_id) on
every task event; TanStack Query only refetches an invalidated query while it
has an active (i.e. expanded) observer, so an open timeline stays live and a
collapsed one just refetches on next expand. This is what lets an operator
see the reason a specific attempt failed even for a task mid-retry, whose
task-level failure_reason has already been cleared — see
Attempt history in the architecture doc.
TypeScript conventions¶
- Strict everywhere.
tsconfigenablesstrict,noUncheckedIndexedAccess, andexactOptionalPropertyTypes. Index access yieldsT | undefined; handle it rather than asserting. Optional properties are not implicitlyundefined- assignable — model truly-absent fields with?. - No
any. Parse unknown input (e.g. WebSocket payloads) asunknownand narrow with type guards, assrc/ws/events.tsdoes. - Path alias. Import with
@/(→src/), not relative../../chains. - Wire types are the contract. Keep
src/api/types.tsandsrc/ws/types.tsaligned with the OpenAPI spec and the server's wire structs; the spec is authoritative when they diverge. - Functional components and hooks only; styles via CSS Modules
(
*.module.css) co-located with the component. - JSDoc on public API. Exported functions and types in
src/api/andsrc/ws/carry JSDoc so editors surface descriptions without a round-trip to the OpenAPI spec.
See the project CONTRIBUTING.md "Web UI contributions"
section for the component testing approach and styling conventions, and
web-accessibility.md for the accessibility baseline.