Skip to content

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:

webui:
  enabled: true        # default: true when admin is configured
  path_prefix: /webui  # default: /webui

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:

webui:
  enabled: false

Accessing the WebUI

Once the router is running with admin authentication configured, navigate to:

http://<host>:<port>/webui/

For example, with the default settings:

http://localhost:8080/webui/

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:

admin:
  auth:
    method: bearer
    bearer_token: "${ADMIN_TOKEN}"

or for HTTP Basic:

admin:
  auth:
    method: basic
    basic_auth:
      username: admin
      password: "${ADMIN_PASSWORD}"

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.method is none (or the client IP is on the allowed_ips list), 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 on allowed_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 localStorage instead, 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; a 403 never 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's started_at / uptime_seconds, advanced locally between polls so it does not visibly freeze between 5 second fetches), and one chip per subsystems.* 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 from GET /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 in GET /admin/stats's total_requests between 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, and GET /admin/circuit/all. A card for a backend that is not healthy sorts 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/all reports: 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, or AWS SigV4, each revealing its own fields. The SigV4 option is gated on the features.bedrock_sigv4 build 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 CLI continuum-router auth login flow, 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 - internal visibility, disaggregated role, Bedrock endpoint type and region (shown only for bedrock), the Anthropic auto_cache_control and fast_mode flags (shown only for anthropic), 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 (available when any serving backend is healthy, degraded when none are, unknown when the status cannot be determined), context window, and price per 1K tokens when metadata is present. Models served only by an internal: true backend are hidden behind a "Show internal" toggle and carry a distinct badge - the admin catalog is the one surface that still lists them, since GET /v1/models hides 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 against GET /admin/backends. "Refresh models" calls POST /admin/models/refresh (the same underlying force-refresh the public POST /v1/models/refresh uses) and shows how long ago the last refresh completed.
  • Aliases - a form editor for the model_aliases config 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 of exact overrides (alias name -> target model). Saving validates through POST /admin/config/validate first and surfaces any error inline before issuing the PUT.
  • Fallback Chains - a visual editor for the fallback.fallback_chains map (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 other fallback field (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/validate and the PUT itself 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 the metrics feature 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 bulk PUT /admin/smart-routing/model-profiles with 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 (when conditions, route_to target), and a Save all that issues a single bulk PUT /admin/smart-routing/policies. Because that endpoint reports validation failures as a 200 OK body ({"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/classify and .../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_behavior read from GET /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 the PATCH /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's guardrails section - and the API key referenced by a provider's api_key_env is 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 through DELETE /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 model and tenant_id inputs, so this always performs a full flush. Because a flush discards every cached response, the confirmation dialog requires typing flush before 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 cachedContents resource; 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/prompts is 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 issues PUT /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" calls POST /admin/config/prompts/reload behind 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_prompts section: the default prompt (inline text or a file reference), the prompts_dir, the merge strategy (prepend / append / replace) and separator with a live preview that mirrors GlobalPromptConfig::merge_prompts client-side, and add/remove rows for per-backend and per-model overrides (each inline text or a file reference). Saving issues PUT /admin/config/global_prompts; a rejected save surfaces the server's error message inline rather than a toast alone. Because global_prompts hot-reloads immediately, a successful save dispatches the same config-applied event 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 via Content-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/files accepts). It size-checks the selection client-side against the configured max_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 browser fetch API 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_plane is compiled) - reads GET /admin/control-plane/status, mounted only in a control-plane-featured build (src/control_plane/status_api.rs). When control_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.acp is true) - status, capabilities, and default model from GET /admin/acp/status; an expandable session table from GET /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 of GET /admin/acp/agent.json.
  • AppProxy (shown when features.appproxy_router or features.appproxy_legacy is compiled) - a mode badge (ROUTER or legacy) and the root, unauthenticated GET /status payload rendered as key/value rows (version, authority, app mode, protocol, occupied/available slots), fetched directly rather than through the admin API since /status is 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-scheme and 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.

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/capabilities once and caches the result in the Alpine capabilities store.
  • Every registered page may declare a capability dot-path (for example "subsystems.guardrails" or "features.control_plane"). A page is hidden from the sidebar when that path resolves to false in 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 enabled flag (for example guardrails.enabled) updates the sidebar without a browser reload.
  • If /admin/capabilities itself 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.js and css/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 Modified responses
  • Cache-Control headers - no-cache for HTML (always revalidates), public, max-age=3600 for 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 real admin.auth.method for any router reachable outside a trusted network - none is fine for local development but the WebUI will keep showing the unprotected-API warning banner as a reminder.
  • The path_prefix must 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: SAMEORIGIN prevents 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 the Function constructor. 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, since script-src is 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

  1. Create js/pages/<id>.js with a component factory and a registerPage({ id: '<id>', title: '...', order: N, icon: '<svg>...</svg>', componentFactory: myFactory }) call.
  2. Create partials/<id>.html whose root element binds to the component: <div x-data="<id>">...</div>.
  3. Add one <script src="../js/pages/<id>.js"></script> tag to index.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:

make webui-css

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:

webui:
  enabled: false

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

webui:
  enabled: true
  path_prefix: /admin/ui

The WebUI will then be available at:

http://localhost:8080/admin/ui/

Ensure your reverse proxy forwards requests to this prefix correctly:

location /admin/ui/ {
    proxy_pass http://localhost:8080/admin/ui/;
}