Otari admin dashboard
August 21, 2026 ยท View on GitHub
A React + HeroUI v3 single-page app for the Otari
gateway's standalone admin panel: browse the model catalog, set model pricing,
manage aliases, and toggle runtime settings (model discovery and default
pricing). It talks to the gateway's management API (/v1/models, /v1/pricing,
/v1/aliases, /v1/settings) on a session. The operator signs in on the
sign-in screen with whichever credential the deployment currently accepts, the
master key until it is claimed and an email and password after, and the gateway
answers with an HttpOnly cookie. The credential is never written to browser
storage: it lives in the form's React state until the request is sent and is
gone on reload. The cookie that replaces it does persist, and the page's own
script cannot read it.
Stack
- React 19 + TypeScript, built with Vite
- HeroUI v3 (
@heroui/react,@heroui/styles) on Tailwind CSS v4 - TanStack Query for data fetching, TanStack Router (file-based) for routes
- Vitest + Testing Library for tests
- Biome for formatting and lint, including the layer boundary (
pnpm run lint,pnpm run lint:fix)
Layout
src/ is four layers plus a test corner:
| Directory | Holds | May import |
|---|---|---|
features/<domain>/ | A domain's page, the parts only it uses, its tests | shared/, client/, other features |
shared/ | components/ primitives, helpers/ pure functions, api/ transport and query hooks, hooks/ cross-cutting React state | client/, itself |
app/ | The composition root: providers, router, shell chrome, and nav/, the sidebar registry and its overlay seam | any layer here |
routes/ | One file per URL, naming a feature's page | any layer here |
tests/ | Test harnesses; outside the boundary, so a harness may mount the app's providers | any layer here |
client/ is generated from the OpenAPI spec, main.tsx is the entry, and
styles/globals.css is the one stylesheet. This is otari-ai/frontend/src's
layout, because that control-plane UI moves into this repo at M5.
pnpm run lint fails a PR that has a feature importing app/, or shared/
importing either of the layers above it. No layer may import an overlay's tree,
which lives in otari-ai and is composed onto this base at build time.
src/architecture.test.ts proves the lint still rejects each of those. See
AGENTS.md for the reasoning.
Develop
cd web
pnpm install
pnpm run dev # Vite dev server on :5173, proxying the API to :8000
pnpm run lint # format + lint, including the layer boundary
pnpm run typecheck
pnpm test
pnpm run dev serves only the SPA, so it proxies /v1 and /health to a
gateway at http://localhost:8000 (see vite.config.ts). Start one first, for
example uv run otari serve --config config.yml, then sign in with that
gateway's master key. To develop against a gateway running elsewhere:
OTARI_DEV_API=https://your-app.up.railway.app pnpm run dev
If the source is edited through a bind mount (an agent working in a container, say) and hot reload misses changes, the host watcher may not see the writes as filesystem events. Fall back to polling:
VITE_USE_POLLING=1 pnpm run dev
Build
pnpm run build
pnpm run build writes the production bundle to ../src/gateway/static/dashboard
(configured in vite.config.ts). That directory is gitignored, not committed:
Vite content-hashes every asset filename, so a committed bundle made any two
branches touching web/src conflict on every file. There is nothing to commit
after a rebuild.
Who builds it instead:
- The Docker image builds it in a
node:22-slimstage (seeDockerfile), so a container ships the dashboard with no action from you. - From a source checkout, run
make dashboard(repo root) once. Without a built bundle the gateway serves the get-started tutorial at/instead. - A wheel ships the dashboard only if the bundle was built before
uv build;make dashboardfirst if you need one that does.
CI (.github/workflows/otari-dashboard.yml) lints, type-checks, tests, and
builds on every change under web/.
How it is served
The gateway serves index.html at / and the hashed assets under /assets
(see src/gateway/main.py and src/gateway/dashboard.py). The app routes on
hash history (/#/models, /#/usage), so no server-side catch-all route is
needed. Both modes serve the same bundle: the page asks GET /v1/bootstrap
which deployment it reached, and a hybrid gateway, having no local management
API, boots into the data-plane landing page instead of the management shell.
Without a built bundle the root falls back to the get-started tutorial in
either mode.