Embedded WebUI¶
Continuum Router includes an embedded browser-based administration interface compiled directly into the binary. No external files, build tools, or Node.js pipeline are needed; the WebUI is always available as part of the single-binary deployment.
Overview¶
The WebUI provides a graphical interface for the same operations available through the Admin REST API. It is useful for:
- Interactive backend health monitoring
- API key lifecycle management (create, rotate, enable/disable, delete)
- Live configuration editing with validation
- Configuration change history and rollback
- Guardrail policy status, per-provider tuning, per-route overrides, and a dry-run test console
- Cache and routing-optimization visibility and purge controls (response cache, KV cache index, Gemini context cache, prefix routing)
The WebUI's own assets (HTML/CSS/JS) load without authentication, so the login view itself is always reachable; every action it performs goes through the Admin API, which is protected by the same admin authentication middleware as the rest of /admin/*. No additional authentication configuration is required.
Configuration¶
The webui section in your configuration file controls the embedded interface:
Configuration Properties:
| Property | Type | Default | Description |
|---|---|---|---|
enabled |
boolean | true |
Enable or disable the WebUI |
path_prefix |
string | /webui |
URL path prefix for the WebUI. Must start with /. Must not contain ... |
If webui is omitted from the configuration, the defaults above apply (WebUI enabled at /webui).
To disable the WebUI entirely:
Accessing the WebUI¶
Once the router is running with admin authentication configured, navigate to:
For example, with the default settings:
On first load the WebUI probes GET /admin/health to detect how the router is protected, then shows one of four views: a login form, a dedicated "Access denied" screen, a "router unreachable" screen, or the app itself.
Authentication¶
Configure admin authentication in the admin section, exactly as for the Admin REST API:
or for HTTP Basic:
The login flow¶
The WebUI's startup probe drives what the operator sees:
- A credential is required (
401) - the login view renders: a toggle between "Bearer token" and "Username / password (Basic)", the matching input field(s), and a "Remember on this device" checkbox. Submitting probes the same endpoint with the candidate credential; a wrong value shows an inline "Invalid credentials" error without leaving the form. - No credential is required (
200) -admin.auth.methodisnone(or the client IP is on theallowed_ipslist), so the WebUI enters directly and shows a dismissible banner: "Admin API authentication is disabled. Anyone who can reach this router can administer it." Dismissing it silences it for the rest of this browser tab session; a new tab, or reopening the browser, shows it again until you configure real authentication. - Access denied (
403) - a dedicated screen explains that the request was rejected (for example, an IP not onallowed_ips) and offers a retry. No login form is shown here, since no credential can fix an IP-level denial. - Network error - a "router unreachable" screen with a retry button.
Credential storage and "Remember on this device"¶
A successful sign-in stores the ready-made Authorization header value (Bearer <token> or Basic <base64(user:pass)>), never the raw credential fields:
- By default it is stored in
sessionStorage, so it is cleared when the browser tab closes. - Checking "Remember on this device" stores it in
localStorageinstead, so it survives a browser restart. This is a real tradeoff: anyone with access to that browser profile (a shared machine, a compromised extension) can administer the router until you sign out or the credential is revoked server-side. Leave it unchecked on shared or untrusted machines.
Session handling¶
Every Admin API call the WebUI makes (adminFetch in js/app.js) attaches the stored Authorization header and reacts to two failure modes globally:
401 Unauthorized- the credential was rejected or revoked server-side. The WebUI clears the stored credential, shows a "Session rejected, sign in again" toast, and returns to the login view. The page you were on is restored automatically after you sign back in.403 Forbidden- a scoped denial on a single action (for example, a key missing a required scope). The WebUI shows a toast but keeps you on the current page; a403never signs you out, since your session credential is still otherwise valid.
Signing out¶
The sidebar footer shows the current auth status ("Signed in (Bearer)", "Signed in (Basic)", or "Auth disabled") and a Sign out button. Signing out clears the stored credential from both sessionStorage and localStorage and returns to the login view - or, when admin.auth.method is none, simply re-probes and re-enters the app with the warning banner shown again.
No credential value is ever written to the browser console, a toast, or a URL.
Pages¶
Dashboard¶
The Dashboard is the operations landing page: an identity strip, stat cards, a traffic trend, backend cards, and a full circuit-breaker detail panel, each linking into the specialized page that owns that data.
- Identity strip - the router version (from the cached capabilities response), a ticking humanized uptime (
GET /admin/health'sstarted_at/uptime_seconds, advanced locally between polls so it does not visibly freeze between 5 second fetches), and one chip persubsystems.*capability flag. A chip for a subsystem with a dedicated page (for example Guardrails, Smart Routing, Files, Caches, Integrations, Usage) navigates there when clicked, whether the subsystem is currently on or off - clicking a disabled subsystem's chip lands on that page's own "Feature disabled" panel rather than doing nothing. - Stat cards - backends healthy of total (from
GET /admin/health), requests and tokens for the most recent daily bucket (the tail of the global series fromGET /admin/stats/series), and the number of open circuits. Each card links to the page that owns the underlying data (Backends, Usage, or the circuit panel further down this page). - Trend row - a 14-day requests sparkline (uPlot, updated in place on each refresh without flicker) built from
GET /admin/stats/series, and an approximate current-rate panel computed client-side from the delta inGET /admin/stats'stotal_requestsbetween polls, explicitly labeled as an approximation since it is derived from polling, not a push feed. - Backend cards show health status, type, weight, and circuit state (when a circuit breaker is configured) per backend, sourced from
GET /admin/backends,GET /admin/config/backends, andGET /admin/circuit/all. A card for a backend that is nothealthysorts first and gets a colored border. A kebab menu on each card offers Edit (deep-links into the Backends page's edit panel for that backend via#backends/<name>) and the three circuit force actions, each behind a confirmation modal. - Circuit breaker panel - ordinary LLM proxy requests drive the same circuit state shown here. The panel renders one entry per backend with everything
GET /admin/circuit/allreports: failure/success counts, consecutive successes, half-open request count, the full statistics block (total/successful/failed requests, success rate, times opened/closed, average open duration), and the last-failure/last-success/last-transition timestamps (plus the next-retry time while a circuit is open). The same three force actions are available here, behind the same confirmation modal used by the backend cards.
Polling: health, backends, circuits, and the current-rate snapshot refresh every 5 seconds; the 14-day series refreshes every 5 minutes. Both cadences share the identity strip's auto-refresh toggle - pausing it stops all network polling, though the uptime counter keeps ticking locally since it needs no new data to stay accurate.
Empty and degraded states: a fresh router with no recorded traffic shows an explanatory empty state in the trend row instead of an empty chart; a router with no circuit breaker configured shows a note in the circuit panel instead of an empty table; and a subsystem that is compiled out or disabled never produces a dead capability chip - see Capabilities and Feature Gating.
API Keys¶
The API Keys page provides full lifecycle management for API keys:
- List all keys with masked values and status indicators
- Create new keys with configurable scopes, backend/model allow-lists, annotations, and rate limits
- Edit existing key metadata (name, scopes, allow-lists, annotations, rate limit)
- Rotate key values (generate a new secret while preserving the key ID)
- Enable or disable keys without deleting them
- Delete keys permanently
- Search and filter by name or status
Scopes render as a checkbox grid built from the router's own recognized scopes (read, write, admin, files - the same set the auth and admin-auth middleware check against). A key loaded from a config file may carry a scope outside that set; editing such a key keeps it visible as a removable chip alongside the checkboxes rather than silently dropping it on save.
Backend and model allow-lists (allowed_backends, allowed_models) restrict which backends a key may route to and which models it may see and use. The backend list is a checkbox grid populated from GET /admin/backends; the model list is a type-and-Enter tag input with suggestions from the model catalog (GET /admin/models). An empty list means no restriction, and both the form and the keys table say so explicitly ("No restriction" / "Unrestricted") rather than leaving it ambiguous. Enforcement happens at the proxy: a request for a model outside a non-empty allowed_models is rejected with 403 Forbidden before it reaches any backend.
Annotations are a free-form key/value map (add/remove rows) for operator metadata such as team, email, or a cost-center tag; they carry no special meaning to the router beyond being exported as Prometheus labels on the api_key_info metric.
The keys table adds a Restrictions badge (e.g. "2 backends, 3 models" or "Unrestricted") and an Annotations badge (a count; hovering shows every key/value pair), plus two lazily-loaded numbers - total requests and total tokens - fetched from GET /admin/stats/api-keys/{id} only for rows that have actually scrolled into view, batched and cached so a table of hundreds of keys does not fire hundreds of eager requests.
Clicking a row (anywhere except the action buttons) opens a read-only detail drawer: every field, both allow-lists as chips, the full annotation table, creation/expiry timestamps, the masked key ID, and a compact 14-day request sparkline sourced from GET /admin/stats/api-keys/{id}/series. Each key row also has a "View usage" action (also reachable from the drawer as "Open full usage") that deep-links into the Usage page's drill-down for that key (#usage/api-keys/<id>).
Usage¶
The Usage page surfaces the router's request and token analytics from the /admin/stats/* family, plus a metrics-history explorer. It is organized into six tabs:
- Overview: stat cards for total requests, prompt tokens, completion tokens, and active keys (from
GET /admin/stats), a requests-per-day line chart, and a stacked prompt-vs-completion tokens-per-day chart. Both charts are backed by the global daily series (GET /admin/stats/series). - Models and Backends: sortable, filterable tables of per-model and per-backend usage, each row showing a share bar proportional to its request count.
- API Keys and Users: master tables of per-key and per-user totals. Clicking a row opens a drill-down with a per-model breakdown table and a per-day usage chart. The API-key drill-down is also the deep-link target for the API Keys page's "View usage" action (
#usage/api-keys/<id>). - Metrics: a metrics-history explorer that plots a persisted Prometheus series over a chosen time range (1h / 6h / 24h / 7d / custom). Pick a metric from the preset list (
http_requests_total,http_request_duration_seconds,backend_current_load,model_usage_total,model_tokens_processed,errors_total) or type any other metric name.
Auto-refresh: on by default at a 30 second interval. Charts update in place without flicker; toggle it off to pause.
Reset statistics: the Overview tab has a danger action that clears every accumulated counter and time series. It requires typing reset in a confirmation modal before it calls POST /admin/stats/reset; the page reloads its data afterward.
Metrics tab availability: the Metrics tab is shown only when the persistent metrics log is active, which requires both the metrics-persistence build feature and the runtime metrics.persistence config. The tab is gated on the subsystems.metrics_persistence_active capability flag and stays hidden otherwise. If the feature is toggled out after the page loaded (a rebuild race), the explorer shows a disabled-feature panel instead of a broken chart.
Backends¶
The Backends page lists every configured backend and exposes the full backend configuration surface through a sectioned deep-edit form.
The list shows one card per backend with a health dot and status, consecutive failures, last check time, and badges for the auth type, the internal flag, the disaggregated role (only when not unified), and the most recent on-demand probe result. Each card has three row actions: Probe now, Edit, and Delete.
Deep-edit slide-over¶
Add Backend and Edit open a slide-over panel with collapsible sections instead of a flat modal:
- Basic - name (immutable on edit), type, URL, weight, organization ID. The URL is optional for
bedrock(it is templated from the region). - Authentication - a radio choice of
api key,oauth,service account, orAWS SigV4, each revealing its own fields. The SigV4 option is gated on thefeatures.bedrock_sigv4build capability and is disabled with a "requires the bedrock-sigv4 build" note otherwise. OAuth here only selects the method; the token store is populated by the CLIcontinuum-router auth loginflow, not from the browser. - Models - the model-name list plus an advanced JSON editor for per-model configs.
- Health check - an override toggle. When off, the backend-type default is used and the global interval and thresholds (which are global-only) are shown for reference. When on, the per-backend endpoint, method, timeout, and accepted status codes are editable, with the global values shown as placeholders.
- Retry - an override toggle exposing max attempts, delays, timeout, backoff multiplier, and jitter, with the global retry policy shown as placeholders.
- Advanced -
internalvisibility, disaggregated role, Bedrock endpoint type and region (shown only forbedrock), the Anthropicauto_cache_controlandfast_modeflags (shown only foranthropic), and external KV storage for prefill/decode roles.
Secrets are write-only. The API returns credentials masked, so the form never displays a stored secret. On edit, the Authentication section is preserved by default; ticking Replace authentication reveals the editor, and any secret field left blank is not sent. The API cannot echo a stored secret back, so a credential can be replaced but not read.
Test connection in the panel footer runs the on-demand probe against a saved backend (it is disabled on the Add form with a "save first" hint) and renders the status, latency, checked URL, and any error inline.
On-demand health probe¶
The Probe now row action and the panel's Test connection button call POST /admin/backends/{name}/check. The probe runs the same check the background health monitor performs for that backend - identical endpoint resolution per backend type, identical auth, and the identical timeout - but is a pure probe: it never mutates the health state machine, so probing a backend does not move it toward a health threshold or trip the circuit breaker. The response reports status (healthy, unhealthy, or warming_up), latency_ms, the checked_url, and an error string on failure.
Models¶
The Models page covers the aggregated model catalog and the two model-centric config sections, across three tabs. It has no capability gate - unlike Guardrails or Smart Routing - since the catalog and both editors are always available. Because the WebUI holds an admin credential and the public /v1/models* endpoints sit behind API-key auth (unreachable in blocking mode), the Catalog tab reads a dedicated admin-only aggregation instead (src/admin_config/models_api.rs):
- Catalog - a searchable table of every model the router knows about, sourced from
GET /admin/models. Each row shows the serving backend names, a derived status (availablewhen any serving backend is healthy,degradedwhen none are,unknownwhen the status cannot be determined), context window, and price per 1K tokens when metadata is present. Models served only by aninternal: truebackend are hidden behind a "Show internal" toggle and carry a distinct badge - the admin catalog is the one surface that still lists them, sinceGET /v1/modelshides them entirely. Clicking a row expands a metadata card (developer, capabilities, max output, thinking/reasoning flag, summary, alias names pointing at it) and per-backend health chips cross-referenced againstGET /admin/backends. "Refresh models" callsPOST /admin/models/refresh(the same underlying force-refresh the publicPOST /v1/models/refreshuses) and shows how long ago the last refresh completed. - Aliases - a form editor for the
model_aliasesconfig section (GET/PUT /admin/config/model_aliases): the five named substring slots (haiku,sonnet,opus,reasoning,default), each a model-select populated from the catalog, plus a table ofexactoverrides (alias name -> target model). Saving validates throughPOST /admin/config/validatefirst and surfaces any error inline before issuing thePUT. - Fallback Chains - a visual editor for the
fallback.fallback_chainsmap (GET/PUT /admin/config/fallback): each chain renders as a primary model followed by an ordered list of fallback models, with add/remove controls for both chains and individual steps. Saving preserves every otherfallbackfield (enabled,mid_stream_enabled,fallback_policy,model_settings) exactly as loaded, since the endpoint replaces the whole section. Client-side checks mirror the server's validation - no self-references, no direct circular references, and model names must match[A-Za-z0-9._-]{1,128}- and block the Save button until fixed;POST /admin/config/validateand thePUTitself remain the authority, so a chain forced through some other client is still rejected server-side with the error surfaced.
model_aliases updates are gradual. The Admin section metadata classifies the whole fallback section as restart-required because its service presence and master switch are startup-built; changes limited to an already-active service's chains and policy are consumed live.
Configuration¶
The Configuration page is a schema-driven editor for the 17 sections exposed by the Admin Config API. It reads the config JSON Schema once per session (GET /admin/config/schema) and the per-section metadata from GET /admin/config/sections (SectionInfo { name, description, hot_reload, configured }), then builds a real form for each section instead of a single raw JSON textarea.
Section list¶
Sections are grouped into Core (server, backends, health_checks, logging), Traffic (rate_limiting, timeouts, retry, circuit_breaker, fallback), and Features (everything else); a supported section the UI does not recognize lands in Features automatically. Each entry shows the section description, a dot for whether it is configured or running on defaults, and a hot-reload badge derived from SectionInfo.hot_reload: immediate (green, "applies instantly"), gradual (blue, "applies to new requests"), or requires_restart (orange, "needs restart").
Schema-driven form¶
For each section the page generates a form from the schema subtree: objects become field groups, primitives become typed inputs (text, number with min/max bounds, checkbox, or enum select), arrays of primitives become tag inputs, and arrays of objects become repeatable rows with add and remove controls. Field descriptions from the schema render as inline hints, and nesting is handled to arbitrary depth (for example timeouts.request.streaming.chunk_interval). Where the schema cannot describe a field meaningfully, such as a free-form map (additionalProperties) or a union, that single field falls back to a scoped JSON sub-editor rather than dropping the whole section to raw JSON. Sections whose root is not an object (for example backends, edited in full on the Backends page, or model_aliases, edited on the Models page) open directly in the Raw JSON view, retain their exact array/scalar/null value, and do not offer a form toggle that cannot represent that value.
A Raw JSON toggle per section switches between the generated form and a raw editor. Both views bind the same underlying object, so unsaved edits survive switching views; invalid raw JSON is reported inline and blocks the switch back to the form.
Secrets¶
Fields the API masks (marked x-sensitive in the schema, or returned as a mask placeholder) render as write-only password inputs where blank means "keep the stored value". The page never sends a mask placeholder back to the router. On save it diffs the edited state against the fetched state and issues a PATCH with only the changed leaves, so an untouched secret is simply absent from the request and the stored value survives the merge. A PUT of the whole section is used only when the section has no secrets and a key was removed (or the section was previously empty). Because JSON arrays are replaced as whole values during a partial update, the review modal blocks a changed array that still contains masked placeholders; use the dedicated section editor, undo that array change, or re-enter every remaining secret before saving.
Review and save¶
Review & save opens a modal that shows the structural diff (added, removed, and changed leaves with their old and new values), the section's hot-reload consequence in plain language, and the result of an automatic validation (POST /admin/config/validate) that runs when the modal opens. Secret values are recursively redacted from the rendered diff, including secrets entered through raw or scoped JSON. For a section-scoped request, the server merges the candidate section over the live configuration, deserializes the complete candidate, and runs the same global validation used by the write path. Validation errors block the save; warnings are shown but allow it. Confirming issues the PATCH (or PUT), and that section mutation itself validates and publishes the candidate snapshot. The optional "Apply now" follow-up and the separate Apply Changes button call POST /admin/config/apply without a full candidate, which is an explicit no-op because section edits are already published; it creates no empty history entry and does not claim a reload. API clients may instead supply a complete config candidate to that endpoint for an atomic validate/diff/preview-or-publish operation. Restart-required fields still need a process restart.
Export and import¶
Export downloads the full configuration (secrets masked) as YAML, TOML, or JSON. Import validates the pasted content first (POST /admin/config/validate) and, for JSON, shows a structural diff against the current configuration before anything is committed; YAML and TOML are validated server-side and applied without an in-browser diff, since the browser cannot parse them without a bundled parser. Only after a successful validation does the Import & apply action send POST /admin/config/import.
Exporting and importing back is safe for credentials: a masked placeholder means "keep the value that is already live", so the round trip leaves every secret in place instead of overwriting it with its own mask. Both the validation step and the import response list the paths that were preserved, and an import whose placeholder cannot be matched to a live value is refused with the failing path named rather than silently guessing. See Masked secrets on the export to import round trip.
Smart Routing¶
The Smart Routing page is hidden unless smart_routing.enabled: true (the subsystems.smart_routing capability); direct navigation to it while disabled shows the standard "Feature disabled" panel naming the smart_routing config section. When enabled it covers the full admin surface across five tabs:
- Status - enabled state, virtual model, default tier, and
intercept_all; a load-state panel (current level, max tier, degradation flags, live snapshot metrics); and an aggregated stats panel (profile/policy counts, classifier cache size, and - when themetricsfeature is compiled in - decision/classification/fallback counters). Auto-refreshes every 15 seconds with a pause toggle. - Model Profiles - a table of explicitly configured (
explicit_exact) model-tier profiles. Add, edit, or remove rows locally, then Save all, which issues a single bulkPUT /admin/smart-routing/model-profileswith the complete edited set and shows a confirmation step summarizing profiles added, changed, and removed. Glob-pattern profiles (model_pattern) and any profile auto-inferred from live traffic are not listed here (the counts are shown as a note) since the bulk endpoint replaces the entire list; pattern profiles are always re-submitted unchanged so a save never drops them silently. - Policies - an ordered list of routing policies with up/down reorder buttons, an inline editor per policy (
whenconditions,route_totarget), and a Save all that issues a single bulkPUT /admin/smart-routing/policies. Because that endpoint reports validation failures as a200 OKbody ({"status": "error", "errors": [...]}) rather than an HTTP error, the page inspects the response body and surfaces each error inline instead of relying on the HTTP status. - Playground - a two-pane dry-run tool: a request builder (plain-prompt or raw-JSON payload) on the left and the result on the right. Classify and Simulate call
POST /admin/smart-routing/classifyand.../simulate, which never route real traffic; each result shows a summarized view plus a collapsible raw JSON response. - Cache - LLM classifier cache stats (entries, capacity, TTL) and a confirm-gated Clear cache action.
History¶
The History page shows a timeline of configuration changes. Each GET /admin/config/history entry carries its metadata (version, timestamp, source, actor, changed sections, description) plus a masked snapshot of the full configuration at that version.
- Expanding an entry shows the structural diff between it and the previous (older) entry, so you see exactly what that change introduced. The oldest entry is labeled as the baseline.
- Rollback opens a confirmation that shows the diff from the current configuration to the version being restored, then issues
POST /admin/config/rollback/{version}.
Snapshots are masked with the same rules as the rest of the admin API, so a secret rotation never appears as a diff and no secret is exposed through the history endpoint.
Guardrails¶
The Guardrails page is hidden unless subsystems.guardrails is true (see Capabilities and Feature Gating); direct navigation to it otherwise shows a "Feature disabled" panel naming the guardrails config section. It covers the six guardrail admin endpoints (src/admin_config/guardrails_api.rs):
- Status strip - the current
enabled/mode/on_error/timeout_ms/block_behaviorread fromGET /admin/guardrails. "Edit mode / on-error" opens a confirmation modal that spells out the consequence of the change (enforce mode blocks traffic that fails a check; fail-closed rejects traffic when a provider errors) before it issues thePATCH /admin/guardrails. - Providers - one card per provider configured in the config file, showing its type, stages, enabled state, and thresholds. A quick Enable/Disable toggle and a full edit modal (enabled, per-category thresholds, timeout override, on-error override) both go through
PUT /admin/guardrails/providers/{name}. Providers cannot be created or deleted from this page - that stays a configuration-file change, on the Configuration page'sguardrailssection - and the API key referenced by a provider'sapi_key_envis displayed masked and read-only; the update endpoint has no field to change it. - Route Overrides - a table of per-route policy overrides (mode, enabled, provider subset, thresholds, allow/deny lists). Add/edit goes through
PUT /admin/guardrails/routes/{route}, delete throughDELETE /admin/guardrails/routes/{route}behind a confirmation modal. Route patterns that contain/(for example a team-scoped route id) are percent-encoded before the request is sent, so the router's single dynamic{route}path segment still matches. - Test Console - a dry run against
POST /admin/guardrails/test: paste sample text, pick the input/output stage, and run it. The endpoint always evaluates every registered provider; the "Show results for" filter only narrows which rows this page displays, it does not change what ran. Each result shows a verdict chip (pass / flag / block / skipped), or a failure chip (Fail-open/Fail-closed) when the provider timed out or errored and the verdict is only the configured fallback. Failure rows also show the sanitized failure kind and reason from the API response, so a fail-open substitute no longer looks identical to a genuine pass. The category and score still appear when present, and a "Raw JSON" fold exposes the full response entry. A dry run does not affect production metrics or the audit log, and the endpoint does not report a per-provider latency figure, so the page shows the round-trip time of the whole test call instead.
Caches¶
The Caches page gives visibility and purge controls over the four caching and routing-optimization subsystems: response cache, KV cache index, Gemini context cache, and prefix-aware routing. Unlike other pages it has no single page-level capability gate; each of the four sections is gated independently on its own subsystems.* flag, since operators commonly enable only one or two of these at a time. A disabled subsystem renders a compact row naming its config section instead of a live card, and if all four are disabled the page shows one explanatory empty state instead of four disabled rows.
- Response cache: hit rate, entry count (of capacity), size, evictions, and backend type (memory or Redis), plus a Redis connection/error summary when that backend is active. The "Invalidate" action clears the entire cache; the endpoint currently ignores its
modelandtenant_idinputs, so this always performs a full flush. Because a flush discards every cached response, the confirmation dialog requires typingflushbefore the button becomes clickable. - KV cache index: index-wide stats (entries, tracked prefixes, total hits/evictions, KV-aware routing share) plus a per-backend table of event-source connection state and index event counts. "Clear Index" empties the router's local routing index; it does not delete any backend-side KV cache data, and routing simply falls back to non-KV-aware selection until the index repopulates from incoming backend events.
- Gemini context cache: tracked entry counts, in-flight creations, hit/miss/create counters, cached-token totals, and a per-backend entry breakdown. "Clear" drops the router's local tracking and issues a best-effort delete for each tracked
cachedContentsresource; anything that delete does not reach still expires on its own by TTL on the Gemini side, and new requests simply re-create caches as needed. - Prefix routing: read-only routing-decision counts (prefix-hash, overflow, fallback), the overflow rate, unique prefix count, and per-backend in-flight request distribution. There is no mutating control here - the endpoint set has no clear or reset action for prefix routing.
All four sections show a loading skeleton on first fetch, an inline error with a Retry button on failure, and an empty-table message when there is nothing to display. Percentages (hit rate, overflow rate) and byte sizes are formatted for readability, with the exact underlying value available in the element's tooltip. An auto-refresh toggle polls every enabled section's stats every 15 seconds; any clear or invalidate action refreshes that section immediately afterward and reports the outcome as a toast.
Prompts¶
The Prompts page manages the global_prompts config section: the router's global system-prompt injection subsystem. It has no capability gate - the section always parses - so it shows a getting-started panel explaining the subsystem (with a "Configure" action) instead of the normal two-panel layout while global_prompts is entirely unconfigured. It is built on the four prompt-file admin endpoints (src/admin_config/prompts_api/handlers.rs) plus the generic GET/PUT /admin/config/global_prompts section endpoints.
- Prompt Files (left panel) - a tree of the prompt files currently referenced by Injection Settings (the default prompt and any backend/model override), each loaded via
GET /admin/config/prompts/{path}into a plain monospace textarea when selected.GET /admin/config/promptsis not a directory listing - there is no such endpoint - so it only returns entries the config actually references; files created in this page's session are kept visible locally until they are wired into a setting and saved. Saving issuesPUT /admin/config/prompts/{path}; "New file" creates an empty file the same way after rejecting..and empty path segments client-side (the server enforces the same restriction). "Reload from disk" callsPOST /admin/config/prompts/reloadbehind a confirmation dialog - it re-reads every configured file from disk and discards nothing entered in the UI. There is no file-delete endpoint, so this page does not offer one. - Injection Settings (right panel, collapsible) - a form over the
global_promptssection: the default prompt (inline text or a file reference), theprompts_dir, the merge strategy (prepend / append / replace) and separator with a live preview that mirrorsGlobalPromptConfig::merge_promptsclient-side, and add/remove rows for per-backend and per-model overrides (each inline text or a file reference). Saving issuesPUT /admin/config/global_prompts; a rejected save surfaces the server's error message inline rather than a toast alone. Becauseglobal_promptshot-reloads immediately, a successful save dispatches the sameconfig-appliedevent the Configuration page uses. - Dirty-state protection - editing a file or the injection settings form sets a dirty flag. Navigating to another file or another page while dirty prompts a confirmation; closing or reloading the browser tab triggers the standard native "leave site" warning.
Files¶
The Files page manages the stored files of the Files API (/v1/files). It is hidden unless files.enabled: true (the subsystems.files capability); direct navigation to it while disabled shows the standard "Feature disabled" panel naming the files config section.
Because the public /v1/files routes authenticate with a router API key (and can enforce per-owner ownership), the WebUI's admin session cannot use them directly. The page is instead backed by admin-only endpoints (src/admin_config/files_api.rs) that reuse the same file service under the admin credential and bypass ownership by design, so the page lists files uploaded by every API key or user together. See Admin Files API for the endpoint reference and its ownership-bypass semantics.
- File table: one row per stored file showing filename, purpose, size, owner (the stored user/key identity, or
-for files with no recorded owner), and creation time. Client-side search filters by filename or owner, and the Filename, Size, and Created columns are click-to-sort. A footer shows the total count and cumulative size. - Download: streams the original bytes from
GET /admin/files/{id}/content(a raw authenticated fetch, mirroring how the Configuration page exports a file), preserving the stored filename viaContent-Disposition. - Details: clicking a filename (or the info action) opens a details modal that re-fetches
GET /admin/files/{id}and shows every stored field, including content type, owner, organization, and source IP. - Delete: a confirmation modal naming the file, then
DELETE /admin/files/{id}; the list refreshes on success. - Upload: a modal with a file picker and a purpose selector (the same purposes
/v1/filesaccepts). It size-checks the selection client-side against the configuredmax_file_size(reported by the list endpoint) before sending, and the server enforces the limit authoritatively (an oversized upload is rejected and the server's error is surfaced in a toast). Upload progress is shown as a simple indeterminate spinner, since the browserfetchAPI does not expose upload progress.
The page shows the usual loading, error, and empty states; the empty state explains what the Files API stores. Downloading, deleting, and uploading are content-only operations: this page does not preview file contents and does not edit the startup retention sweep (files.retention_days, on the Configuration page).
Integrations¶
The Integrations page is a read-only status view over the router's external integration subsystems: the Continuum Hub control-plane agent, the ACP agent, and the AppProxy worker. It is hidden unless at least one of them is compiled and configured - the nav entry declares an array capability (features.control_plane, subsystems.acp, features.appproxy_router, features.appproxy_legacy), which is an OR: a default build with none of them enabled shows no nav entry at all. Each card below is additionally, independently gated on its own capability flag, since a build can compile more than one integration but only actually configure some of them. Every card polls its endpoint(s) every 15 seconds under the shared header auto-refresh control (see Appearance and auto-refresh) and shows an inline error row with a Retry button on fetch failure instead of dropping the card. Nothing on this page mutates state - control-plane actions (enroll/unenroll, policy edit) are hub-owned, ACP session termination and AppProxy circuit management are out of scope.
- Continuum Hub (shown when
features.control_planeis compiled) - readsGET /admin/control-plane/status, mounted only in acontrol-plane-featured build (src/control_plane/status_api.rs). Whencontrol_plane.enabled: false, the endpoint replies{"enabled": false}and the card explains the config switch instead of showing agent data. Otherwise it shows a status dot (green: enrolled with a fresh heartbeat; yellow: still enrolling, or a heartbeat older than 3x the reported interval (5 minutes when the interval is not yet known); red: an enrollment or heartbeat error is present) alongside the enrolled router id and hub host (credentials and path stripped), plus heartbeat recency, policy-sync state (revision and key-table size, when policy sync is on), usage-push recency and pending queue depth, and batch-pool occupancy (active jobs of capacity, when batch dispatch is on). - ACP Agent (shown when
subsystems.acpis true) - status, capabilities, and default model fromGET /admin/acp/status; an expandable session table fromGET /admin/acp/sessions(id and age, once session tracking is wired into the admin API - the endpoint currently always reports zero sessions); and a collapsed raw view ofGET /admin/acp/agent.json. - AppProxy (shown when
features.appproxy_routerorfeatures.appproxy_legacyis compiled) - a mode badge (ROUTERorlegacy) and the root, unauthenticatedGET /statuspayload rendered as key/value rows (version, authority, app mode, protocol, occupied/available slots), fetched directly rather than through the admin API since/statusis not mounted under/admin.
Keyboard shortcuts and navigation¶
The WebUI is fully keyboard operable. A command palette and a small set of shortcuts speed up navigation without leaving the keyboard. Every shortcut is suspended while focus is in a text input, textarea, or select, so typing is never intercepted.
Command palette¶
Press Ctrl+K (or Cmd+K on macOS) anywhere to open the command palette. It lists every page you can see (respecting capability gating) followed by the primary action each page contributes ("Create API key", "Add backend", "Refresh model catalog", "Flush response cache", and so on). Type to filter with a fuzzy subsequence match, use the arrow keys to move, Enter to run the highlighted entry, and Esc to close. Running a page action navigates to that page first if you are not already on it, then performs the action. The header "Search" button opens the same palette.
Pages register their own commands through a registerCommand({ id, title, keywords, page, run }) hook and expose the actions those commands invoke with registerPageActions(pageId, handlers) in the component's init() (dropped again in destroy()). See Adding a page.
Page-jump chords¶
Press g then a page's letter to jump straight to it (a gmail-style two-key chord). The letters are shown in the palette footer and in the cheat sheet:
| Chord | Page | Chord | Page |
|---|---|---|---|
g d |
Dashboard | g a |
Caches |
g k |
API Keys | g p |
Prompts |
g u |
Usage | g f |
Files |
g b |
Backends | g m |
Models |
g c |
Configuration | g r |
Guardrails |
g s |
Smart Routing | g i |
Integrations |
g h |
History |
Chords for capability-gated pages only work when that page is available.
Cheat sheet¶
Press ? to open a modal listing every shortcut and page-jump chord.
Modals, focus, and screen readers¶
Every dialog traps focus while it is open, closes on Esc, and returns focus to the control that opened it (role="dialog"/alertdialog with aria-modal and a labelled title). Icon-only buttons carry aria-labels, toasts announce themselves (role="status" for info and success, role="alert" for warnings and errors), navigation landmarks (nav, main, header) are in place, and a visible focus ring is drawn for keyboard focus throughout. Health, circuit, and enrollment indicators pair their color with a text label or icon so state never depends on color alone.
Appearance and auto-refresh¶
Dark mode¶
The sidebar footer has an auto / light / dark theme toggle (a single cycling button when the sidebar is collapsed to an icon rail). The choice is persisted in localStorage under webui_theme:
- Auto (the default) follows the operating system's
prefers-color-schemeand updates live when it changes. - Light and Dark pin the theme regardless of the OS setting.
The theme is applied before the first paint (from js/app.js, so there is no flash of the wrong theme), and it drives both the Tailwind dark: styles and the hand-written styles (scrollbars, badges, config diffs). Contrast was audited in both themes.
Global auto-refresh¶
Several pages poll the router on a timer (the Dashboard every 5 seconds; Caches, Smart Routing, and Integrations every 15 seconds; Usage every 30 seconds). A single auto-refresh indicator in the header shows whether polling is live (a pulsing green dot) or paused, names the currently polling pages in its tooltip, and pauses or resumes every page's polling from one place. The paused state is persisted in localStorage under webui_refresh_paused. Pages keep their own intervals; the indicator only gates them, so the old per-page auto-refresh checkboxes were removed.
Responsive layout¶
The layout adapts to the viewport: the full sidebar on laptops and desktops (>= 1024px), an icon rail on tablets (768-1023px, with a collapse toggle on desktop persisted in localStorage under webui_sidebar_collapsed), and a hamburger drawer on phones (< 768px). Tables scroll horizontally inside their own container, stat-card grids reflow to fewer columns, and centered modals become full-screen sheets below 640px, so every page stays operable with no clipped controls and no horizontal page scroll at 375, 768, and 1280px.
Deep links and unknown pages¶
The Usage and Configuration pages keep light per-page state in the hash query so a refresh or a shared link restores it: the Usage tab (#usage?tab=models) and the selected Configuration section (#config?section=timeouts). Navigating to a hash that does not match any page (a stale bookmark, a typo) shows a friendly "Page not found" panel with links to the available pages rather than a blank screen. Each page also sets a matching browser tab title (for example Continuum Router - Usage).
Capabilities and Feature Gating¶
The router compiles a subset of subsystems depending on which Cargo features were enabled at build time, and each optional subsystem can additionally be turned off at runtime via its config section. GET /admin/capabilities reports both dimensions so the WebUI never renders a page for something the running binary and configuration cannot actually do. See the Capabilities API reference for the full response shape.
How the shell uses it:
- On load, the shell fetches
/admin/capabilitiesonce and caches the result in the Alpinecapabilitiesstore. - Every registered page may declare a
capabilitydot-path (for example"subsystems.guardrails"or"features.control_plane"). A page is hidden from the sidebar when that path resolves tofalsein the cached response. - Direct hash navigation to a hidden page (a bookmark, a shared link, or browser back/forward) does not render a blank or broken page. It shows a "Feature disabled" panel naming the page and, where known, the config section to check.
- The capabilities cache is refreshed automatically whenever a configuration apply, section save, import, or rollback succeeds, so toggling a subsystem's
enabledflag (for exampleguardrails.enabled) updates the sidebar without a browser reload. - If
/admin/capabilitiesitself is unreachable, the shell treats every capability as available (fails open) and logs a warning to the browser console, so a WebUI running ahead of an older or misconfigured router still functions rather than hiding everything.
About panel: the sidebar footer shows the router version and an "About" button. Opening it displays the full capability list (every compiled feature and every runtime subsystem, each with an on/off badge) plus a "Refresh capabilities" button for a manual re-check.
Technology Stack¶
The WebUI is built with:
- Alpine.js - Lightweight reactivity for interactive components. Vendored at
js/vendor/alpine.min.js, not loaded from a CDN. - uPlot - Small, fast charting library for the dashboard and analytics pages. Vendored at
js/vendor/uplot.min.jsandcss/vendor/uplot.min.css. - Tailwind CSS - Utility-first styling. A prebuilt stylesheet (
css/tailwind.css) is generated by the standalone Tailwind CLI and committed to the repository. There is no browser runtime and no CDN. - Vanilla JavaScript - No framework dependencies beyond Alpine.js. A system font stack is used (no web fonts).
Every asset is vendored and served from the router's own origin, so the WebUI loads fully styled and interactive with the network disabled (air-gapped). Pinned versions and upstream URLs are recorded in src/webui/assets/vendor/README.md.
Assets are embedded using rust-embed and served with:
- ETag-based caching - Content-addressed ETags enable
304 Not Modifiedresponses - Cache-Control headers -
no-cachefor HTML (always revalidates),public, max-age=3600for JS/CSS - Security headers -
X-Content-Type-Options: nosniff,X-Frame-Options: SAMEORIGIN - Content Security Policy -
self-only; no third-party origin (see Security Considerations)
Security Considerations¶
- The WebUI's static assets (HTML/CSS/JS) are served without authentication so the login view can load; every action taken from the UI goes through the Admin API, which enforces
admin.auth(see Authentication). Configure a realadmin.auth.methodfor any router reachable outside a trusted network -noneis fine for local development but the WebUI will keep showing the unprotected-API warning banner as a reminder. - The
path_prefixmust start with/and must not contain..to prevent path traversal. - File paths within the asset server are also validated against traversal sequences (
.., null bytes) before serving. - The Content Security Policy names no third-party origin at all. Every asset is served from
self, so the WebUI works air-gapped. X-Frame-Options: SAMEORIGINprevents clickjacking from cross-origin frames.
Content Security Policy¶
The index.html response sets this policy:
default-src 'self'; script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; font-src 'self'; connect-src 'self'; img-src 'self' data:; frame-ancestors 'self'
No CDN, font host, or other remote origin appears. img-src allows data: URIs (for inline SVG data), and everything else is restricted to the router's own origin.
Two relaxations remain, both origin-local:
script-src 'unsafe-eval'- The standard Alpine.js build compiles reactive expressions (x-text,@click, ...) with theFunctionconstructor. The Alpine CSP build (@alpinejs/csp) removes this need, but it forbids inline expressions in templates and would require rewriting every binding across all pages. That rewrite is out of scope for the vendoring/foundation work, so'unsafe-eval'is retained as a documented tradeoff. It never permits loading remote code, sincescript-srcis still restricted to'self'.style-src 'unsafe-inline'- Alpine toggles element visibility with inline styles (x-show,x-transition).
Development¶
The WebUI is a modular single-page app with no bundler and no Node.js build step for cargo build. Assets live under src/webui/assets/ and are embedded with rust-embed.
Asset layout¶
src/webui/assets/
├── index.html # shell: login gate, sidebar, header, toast container, page mount point
├── css/
│ ├── tailwind.css # generated, committed (see below)
│ ├── style.css # hand-written overrides
│ └── vendor/uplot.min.css
├── js/
│ ├── app.js # shell: page registry, router, adminFetch, stores, utils
│ ├── auth.js # login gate: auth probe, `auth`/`login` stores, credential storage
│ ├── vendor/alpine.min.js
│ ├── vendor/uplot.min.js
│ └── pages/ # one file per page (component factory + registerPage)
│ ├── dashboard.js
│ ├── apikeys.js
│ ├── backends.js
│ ├── config.js
│ ├── guardrails.js
│ ├── history.js
│ ├── caches.js
│ ├── smart-routing.js
│ ├── prompts.js
│ └── models.js
├── partials/ # one HTML fragment per page
│ ├── dashboard.html
│ ├── apikeys.html
│ ├── backends.html
│ ├── config.html
│ ├── guardrails.html
│ ├── history.html
│ ├── caches.html
│ ├── smart-routing.html
│ ├── prompts.html
│ └── models.html
└── vendor/README.md # pinned versions and upstream URLs
Page registry¶
js/app.js exposes registerPage({ id, title, navLabel, icon, order, capability, componentFactory }). Each js/pages/<page>.js file calls it at load time. The shell builds the sidebar from the registry (sorted by order) and registers one Alpine component per page, so parallel work never edits shared navigation markup.
On first navigation to a page the shell fetches partials/<id>.html, injects it into the page mount point, and initializes the page's Alpine subtree. Fetched partials are cached in memory; a fetch failure shows a visible error with a Retry button. A missing partial returns a real 404 (it is never silently replaced by the shell).
The capability field is a dot-path into the GET /admin/capabilities response (for example "subsystems.guardrails"), or an array of dot-paths for a page backed by more than one independent subsystem (for example the Integrations page's ["features.control_plane", "subsystems.acp", "features.appproxy_router", "features.appproxy_legacy"]) - an array is an OR, so the page shows when any one path resolves truthy. See Capabilities and Feature Gating below for how it is resolved.
Adding a page¶
- Create
js/pages/<id>.jswith a component factory and aregisterPage({ id: '<id>', title: '...', order: N, icon: '<svg>...</svg>', componentFactory: myFactory })call. - Create
partials/<id>.htmlwhose root element binds to the component:<div x-data="<id>">...</div>. - Add one
<script src="../js/pages/<id>.js"></script>tag toindex.html.
No changes to the sidebar markup, the router, or the other pages are needed.
Regenerating the Tailwind stylesheet¶
css/tailwind.css is a prebuilt stylesheet generated by the standalone Tailwind CLI, which scans the templates and page scripts for the utility classes actually used. It is committed, so cargo build and CI need no Node.js toolchain and no network.
Regenerate it after editing templates or page scripts that introduce Tailwind classes not already in the file:
The target downloads the pinned standalone Tailwind CLI (v4.1.11) to .cache/tailwindcss on first run, or reuses a local binary via TAILWINDCSS_BIN. Because the CLI version and inputs are pinned, regenerating with an unchanged template set produces no diff. The Tailwind source lives at src/webui/tailwind.input.css (it is not served or embedded); it declares the @source globs and the theme. If a class you added does not take effect, it is almost always because make webui-css has not been re-run.
Disabling the WebUI¶
If you do not need the browser interface, disable it to reduce the attack surface:
When disabled, all requests to the configured path_prefix will return 404 Not Found.
Custom Path Prefix¶
To serve the WebUI at a different path (for example, behind a reverse proxy):
The WebUI will then be available at:
Ensure your reverse proxy forwards requests to this prefix correctly: