This document describes the operator-facing web interface delivered in
P4. It complements admin.md, which covers the underlying
session, CSRF, lockout, and audit subsystems shared with the JSON API.
If you are looking for gate semantics, wire formats, or curl examples,
start there; this document is about the browser surface on top.
The admin UI is a single-page application embedded in the gateway-hub,
gateway-proxy, and gateway-door binaries as a static asset tree. At
runtime the tree is served from go:embed through the same admin gate
that fronts the JSON API, so the UI shares exactly one authentication
boundary with the API it calls. There is no separate web server, no
external CDN dependency, and no build step: all CSS and JavaScript are
hand-written, ship under 200 KB uncompressed, and run with no
third-party frameworks.
The UI is an SPA in the literal sense — one HTML document, hash-router
navigation, fetch-based API calls. Each "page" described below is a
pure DOM render driven by a small controller module; the browser never
leaves index.html during normal use.
The UI is reachable only under the hidden admin prefix:
https://your-domain.example/EXAMPLE-SLUG/EXAMPLE-TOKEN-A/EXAMPLE-TOKEN-B/
On first load the gate mints a gw_adm session cookie scoped to that
three-segment prefix and 302s the browser to the trailing-slash URL.
Every subsequent request carries the cookie instead of the slug and
tokens, so the address bar displays the clean prefix once the initial
load completes.
For mutating calls the UI reads the X-CSRF-Token value from the
response headers of any safe-method request (typically GET /api/me
fired at page load) and echoes it on every subsequent POST, PUT, PATCH,
and DELETE. A 403 on a mutating call is nearly always a stale token;
the client code retries once after re-reading /api/me before
surfacing an error to the operator.
See admin.md for the full session lifecycle, idle and
absolute TTLs, CSRF rejection semantics, per-IP lockout tiers, and
audit event shape.
Two themes ship side by side, both rendered with a neumorphism
vocabulary — soft raised surfaces, dual-direction inset shadows, no
hard borders. The default is dark (neumorphism black) and the
alternative is light (neumorphism white). The root <html> element
carries a data-theme="dark" or data-theme="light" attribute; all
component styling is expressed as CSS custom properties keyed on that
attribute, so a theme swap is a single DOM write with no reflow beyond
the repaint.
The toggle lives in the top-right of the persistent header next to the
language picker. Clicking it writes the new value to the data-theme
attribute and persists the choice under the gw.admin.theme key in
localStorage. The next page load reads that key before any styles
paint to avoid a flash of the wrong theme.
There is no automatic prefers-color-scheme follow — the choice is
explicit. Operators who want the system colour scheme can set it once
and forget; the UI will not override it on system-level theme changes.
Screenshots placeholder — P5 documentation pass will add light and dark captures of the Dashboard, Tenants list, and Audit browser here.
Three interface languages are built in: English (en), Russian (ru),
and Simplified Chinese (zh). Catalogs are JSON files shipped in the
binary under internal/i18n/catalogs/ and loaded into the JS runtime
on first render. The catalog format is flat {"key": "text"}; every
translatable string in the HTML carries a data-i18n="namespace.key"
attribute that the renderer resolves before the element becomes
visible.
Language selection order:
- The
?lang=query parameter on the current URL, if present and a known catalog (e.g.?lang=ru). The value is also written tolocalStorageso the override sticks across navigations. - The persisted
gw.admin.langkey inlocalStorage. - The browser's
navigator.languageprefix, if it matches a shipped catalog. enas the final fallback.
The picker in the header lets operators change language without
reloading. Keys missing from the selected catalog fall back to English;
keys missing from English render as the raw key in square brackets
(e.g. [nav.tenants]) so missing strings are visible in review
without breaking layout.
New catalogs live next to the existing ones under
internal/i18n/catalogs/<code>.json. Copy en.json, translate every
value, and leave the keys unchanged. Add the language code to the
selector in internal/admin/ui/static/js/i18n.js. No code change is
needed beyond that; the catalog loader discovers any *.json file at
startup. Translations are not required to be complete — partial
catalogs are valid and fall back key-by-key.
Every page is keyed on the URL hash (#/dashboard, #/tenants, etc.)
so the browser back/forward buttons move between pages without
triggering a full reload. Hub-only pages are hidden from the navigation
on proxy and door nodes — the /api/me response supplies the
node_type that drives the nav render.
Landing page. Shows current RPS, error rate, active backends, and the last ten audit events. Used for at-a-glance health on a freshly-opened session.
- Routes:
GET /api/metrics/history,GET /api/audit?limit=10 - Available on: hub, proxy, door
- Actions: link-out to Metrics and Audit pages
CRUD for tenant records. The list view shows host, backend count, and feature override summary; clicking a row opens a detail panel with the full YAML-equivalent JSON editor and per-feature override toggles.
- Routes:
GET/PUT/DELETE /api/tenants,GET/PUT /api/tenants/{host} - Available on: hub only
- Actions: create, edit, delete, download as YAML
Read-only view of the mirror-health registry. Each row shows the
mirror host, latest verdict (live/degraded/blocked/unknown),
last check timestamp, and per-region verdict breakdown. A filter row
narrows by verdict or by tenant.
- Routes:
GET /api/mirrors - Available on: hub only
- Actions: filter, sort, link-out to the owning tenant
Interactive editor for the global and per-tenant regex blocklists. Patterns are validated client-side against the same regex engine the proxy uses; an invalid pattern blocks the save.
- Routes:
GET/PUT /api/globals,GET/PUT /api/tenants/{host} - Available on: hub only
- Actions: add, edit, remove pattern; preview match against a test input
Toggle grid for the nine P1 features plus the two hub-startup ones. Each row shows the current global state, a switch, and a description sourced from the i18n catalog. On hub nodes the feature registry is global; on proxy and door nodes the same grid shows the local node's view.
- Routes:
GET /api/features,POST /api/features/{name}/toggle - Available on: hub, proxy, door
- Actions: toggle enabled state
Registry of known nodes. Shows node ID, type (hub/proxy/door), last seen, transport kind, and health.
- Routes:
GET /api/tenants,GET /api/mirrors(derived view) - Available on: hub only
- Actions: link-out to the node's own admin URL if the operator has its slug/tokens cached locally
Live metrics view. Prometheus text snapshot in a scrollable pre- formatted block, plus a sparkline strip driven by the in-memory history buckets.
- Routes:
GET /api/metrics,GET /api/metrics/history?limit=N - Available on: hub, proxy, door
- Actions: copy snapshot, adjust history limit
Paginated browser of the append-only audit log. Filters by since
timestamp, actor session ID, action prefix, and target substring.
- Routes:
GET /api/audit?since=...&limit=... - Available on: hub, proxy, door
- Actions: filter, jump-to-timestamp, export visible page as JSON
Planned integration point for automated domain provisioning. The P4 page is a stub: it shows the configured provisioner (or "not configured") and a placeholder for the P5 flow. No mutating routes yet.
- Routes:
GET /api/globals(read-only reference) - Available on: hub only
- Actions: none (stub)
The P4 UI does not ship keyboard shortcuts beyond what the browser
itself provides. Tab navigation follows the DOM order, Enter submits
focused forms, and Esc closes open modal panels. A dedicated
shortcut layer (g d for Dashboard, g t for Tenants, etc.) is
reserved for a later iteration; the controller modules already accept
a shortcut registration hook so the addition is purely additive.
Adding a new page to the SPA is a four-step change, all under
internal/admin/ui/:
- HTML fragment. Add a template in
internal/admin/ui/static/html/<page>.htmlthat defines the page's root element with adata-page="<name>"attribute anddata-i18nhints on every user-visible string. The loader fetches the fragment on first navigation to that page. - JS controller. Create
internal/admin/ui/static/js/pages/<page>.jsthat exportsinit(root)anddestroy().initis called after the fragment is attached; it wires event listeners, fires initial fetches, and returns.destroyis called before the page is swapped out so pending fetches can be aborted. - Navigation entry. Add an entry to the nav array in
internal/admin/ui/static/js/nav.jswith the URL hash, label key, icon, andnodeTypesarray (e.g.["hub"]for hub-only pages). The renderer hides entries whosenodeTypesdoes not include the current node type. - i18n keys. Add every
data-i18nkey used in the fragment to each ofen.json,ru.json,zh.jsonunderinternal/i18n/catalogs/. Missing keys render as bracketed placeholders, so partial catalogs are visible in review before translators fill them in.
No build step is required: the Go go:embed in ui_embed.go picks up
every file under internal/admin/ui/ at compile time. Run
go test ./internal/admin/... after the change to exercise the UI
mount's ServeHTTP path against the new file.
The UI targets evergreen browsers and relies on fetch, ES2022 modules,
CSS custom properties, and Intl.DateTimeFormat. Minimum supported
versions:
- Chrome 110+
- Firefox 115+
- Safari 16+
- Edge 110+
Internet Explorer is explicitly unsupported; the UI will render a single text line asking the operator to use a modern browser, and no further JavaScript runs.
- The Auto-Domains page is a stub. It displays the configured provisioner or "not configured"; no mutating route is wired.
- Metrics history is held in memory on each node. A node restart drops all buckets; long-term history must be scraped from an external Prometheus-compatible store.
- The audit query endpoint caps
limitat 5000 per request. The UI paginates bysincetimestamp for ranges larger than that. There is no full-text index — filtering is a linear scan of the BoltDB index. - Dark-mode detection is manual only.
prefers-color-schemeis ignored once the operator has made an explicit choice, and the default on a fresh profile is always dark regardless of the system setting. - There is no multi-operator coordination. Two operators editing the same tenant from different sessions will race; the second PUT wins. The audit log preserves the sequence but the UI does not show a warning banner.
- Mirror weight and manual-block controls are reserved but not wired in the P4 release. Mirror CRUD appears read-only in the UI until the routes land.
The most common cause is a browser that silently dropped the session
cookie — usually because the admin URL is being served over plain HTTP
and the cookie's Secure attribute caused it to be ignored. Check
that the URL starts with https://. A secondary cause is a stale
cached asset after an upgrade; hard-reload (Ctrl+Shift+R or the
browser's "empty cache and reload" action) clears it.
The UI reads the CSRF token from the response headers of a
safe-method request and caches it for the life of the page. If the
session idle TTL expires while the page is open, the next mutating
request sees a 403. Refresh the page; the SPA will re-read
/api/me and pick up the fresh token.
If refreshes keep failing, the session's absolute TTL (default 8 hours) has elapsed. A full navigation to the admin URL mints a new session.
A loop where every request returns 404 usually means the slug or
tokens were rotated. The gate strips the prefix match in constant
time regardless of outcome, so a rotated slug looks byte-identical
to an unrouted path. Check config.yaml on the server against the
URL in the browser; if they differ, the installer was re-run. See
admin.md for the rotation
procedure.
A loop where the browser keeps bouncing between a 302 and the login URL points at the session cookie being rejected by the browser. Check that the admin URL is HTTPS and that the browser is not in a private-mode window with third-party cookies disabled for the origin.