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/*. When admin.auth is absent the API fails closed and the login view cannot enter. Configure a method, or use explicit admin.auth.method: none only for isolated development.
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.methodis explicitlynoneorip_whitelistaccepted the client address, so the WebUI enters directly. Explicitnoneshows 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 - shows 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. Clicking a chip for a subsystem with a dedicated page (Guardrails, Smart Routing, Files, Caches, Integrations, Usage) navigates there whether the subsystem is on or off; clicking a disabled chip lands on that page's "Feature disabled" panel. - 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:- Unhealthy backend cards sort first and get a colored border.
- A kebab menu on each card offers Edit (deep-links into
#backends/<name>) and the three circuit force actions behind a confirmation modal.
- Circuit breaker panel - ordinary LLM proxy requests drive the same circuit state shown here. Renders one entry per backend with everything
GET /admin/circuit/allreports:- Failure/success counts, consecutive successes, half-open request count, and the full statistics block (total/successful/failed requests, success rate, times opened/closed, average open duration).
- Last-failure, last-success, and last-transition timestamps (plus the next-retry time while a circuit is open), along with the same three confirmation-gated force actions.
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.
Anthropic attribution assigns a key's traffic to an end customer with a profile ID in the form uprof_ followed by 1 to 128 letters, digits, underscores, or hyphens. Clearing the field removes the per-key profile. Enable Trust the caller's profile header only for a gateway key: one valid anthropic-user-profile-id header can replace the configured profile, while malformed or repeated headers are ignored. See Per-customer attribution for Anthropic.
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, durability, the internal flag, the disaggregated role (only when not unified), and the most recent on-demand probe result:
- Durability badges: Shown as config-managed, runtime · durable, or runtime · memory only. A banner above the list indicates whether newly added backends survive a restart (enable durable additions by setting
backends_persistence_fileand restarting), and the create-success toast confirms whether the new backend was saved to the sidecar or only in memory. - Row actions: Each card provides 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:
- Pure probe: Runs the same check as the background health monitor (identical endpoint resolution per backend type, auth, and timeout) without mutating the health state machine or tripping the circuit breaker.
- Redacted response: Reports
status(healthy,unhealthy, orwarming_up),latency_ms,checked_url, and anerrorstring on failure. Bothchecked_urlanderrorredact embedded passwords (for example,http://svc:secret@host:8000is reported ashttp://svc:***@host:8000/health), whileunix:<path>socket addresses have no userinfo and are reported in full.
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 serving backend names, a derived status (
availablewhen any serving backend is healthy,degradedwhen none are,unknownotherwise), context window, and price per 1M tokens when metadata is present. - Models served only by an
internal: truebackend are hidden behind a "Show internal" toggle with a distinct badge (GET /v1/modelshides them entirely). - Clicking a row expands a metadata card (developer, capabilities, max output, thinking/reasoning flag, summary, alias names) and per-backend health chips cross-referenced against
GET /admin/backends. - "Refresh models" calls
POST /admin/models/refreshand shows when the last refresh completed; if live discovery fails during a refresh, last-known rows for that backend remain in the table with adegradedbadge.
- Each row shows serving backend names, a derived status (
- 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 chains and individual steps.
- Saving preserves every other
fallbackfield (enabled,mid_stream_enabled,fallback_policy,model_settings) as loaded, sincePUTreplaces the whole section. - Client-side checks mirror server validation (no self-references, no direct circular references, and model names restricted to 1--128 bytes of Unicode letters, digits,
-,_,.,/, and:, allowing HuggingFaceorg/modeland Ollamaname:tagIDs) and block Save until fixed, whilePOST /admin/config/validateandPUTremain authoritative.
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").
The badge describes the section, and a few individual fields are stricter than the section they sit in. timeouts is badged immediate, but timeouts.limits holds the validation ceilings, which are pinned from the boot configuration: a save that changes them is rejected with a restart-required message rather than applied. health_checks is badged gradual, but health_checks.prewarm_timeout bounds a probe that runs once before listeners bind, so it can only take effect on the next restart. Each such field carries its own x-hot-reload marker in the schema, and the field description states the consequence.
The admin section is startup-owned rather than editable at runtime. Section saves refuse it, and full-document import, apply, or rollback accepts it only when it is identical to the active startup value. Exports omit admin.auth, so a masked export can be imported without weakening the running authentication policy.
Schema-driven form¶
For each section, the page generates a form from the schema subtree:
- Mapped controls: Objects become field groups, primitives become typed inputs (text, bounded number, checkbox, or enum select), primitive arrays become tag inputs, and object arrays become repeatable rows with add/remove controls. Field descriptions render as inline hints, and nesting is supported to arbitrary depth (for example,
timeouts.request.streaming.chunk_interval). - Scoped and raw JSON fallbacks: Free-form maps (
additionalProperties) and unions fall back to a scoped JSON sub-editor for that single field rather than dropping the entire section to raw JSON. Sections whose root is not an object (such asbackendsormodel_aliases) open directly in the Raw JSON view and retain their exact array/scalar/null value. - Raw JSON toggle: Switches between the generated form and a raw editor per section. Both views bind the same underlying object so unsaved edits survive switching; invalid JSON is reported inline and blocks switching back to the form.
Secrets¶
Fields masked by the API (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":
- Partial
PATCHon save: The page never sends a mask placeholder back to the router. On save, it diffs the edited state against the fetched state and issues aPATCHcontaining only changed leaves, leaving untouched secrets intact. - Full
PUTand array rules: A full-sectionPUTis 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 any modified array that still contains masked placeholders; use the dedicated section editor, undo the array change, or re-enter every remaining secret before saving.
Review and save¶
Review & save opens a modal showing the structural diff (added, removed, and changed leaves), the section's hot-reload behavior, and the result of automatic validation (POST /admin/config/validate):
- Secret redaction and validation: Secret values (including those entered via raw or scoped JSON) are recursively redacted from the diff. The server merges the candidate section over the live configuration and runs global validation; errors block saving, while warnings allow it.
- Publishing changes: Confirming issues the
PATCH(orPUT), which validates and publishes the candidate snapshot directly. CallingPOST /admin/config/applywithout a full candidate (via "Apply now" or Apply Changes) is an explicit no-op because section edits are already published, whereas API clients can pass a completeconfigcandidate for an atomic validate/diff/publish operation. Restart-required fields still require 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 six tabs:
- Status - displays current configuration and runtime state (auto-refreshes every 15 seconds with a pause toggle):
- Enabled state, virtual model, default tier,
intercept_all, active classifier method, and LLM classifier availability (GET /admin/smart-routing/status). - Load-state panel: current level, max tier, degradation flags, live snapshot metrics, and engine-side KV cache usage, waiting requests, and fresh-backend count (shown as
-when no fresh engine snapshot reported them). - Aggregated stats panel: profile/policy counts, classifier cache size, and (when
metricsis compiled in) decision, classification, and fallback counters.
- Enabled state, virtual model, default tier,
- Model Profiles - a table of explicitly configured (
explicit_exact) model-tier profiles:- Add, edit, or remove rows locally, then click Save all to issue a bulk
PUT /admin/smart-routing/model-profilesafter a confirmation summary. - Glob-pattern profiles (
model_pattern) and traffic-inferred profiles are excluded from the table (their counts appear as a note), and pattern profiles are automatically re-submitted unchanged so saving never drops them.
- Add, edit, or remove rows locally, then click Save all to issue a bulk
- 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. - Classifier - edits
config.classifierthroughGET/PATCH /admin/config/smart_routing(HotReloadCapability::Immediate), rebuilding the LLM classifier against the live backend pool without a restart:- Exposes the classifier method (
rule/llm/hybrid), the rule confidence threshold (shown forrule/hybrid), and core LLM classifier settings (model, backend, cache TTL, max cache entries, and disable-at load state, shown forllm/hybrid). - Advanced LLM fields (timeout, temperature, token limits, structured output strategy, prompts) remain on the Configuration page, and out-of-range values surface validation errors inline.
- Exposes the classifier method (
- 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.
When admin.history_dir is configured, each file-edit hot reload is persisted into that node-local directory (mode 0600) using the raw configuration text before ${VAR} variable interpolation, bounded by admin.max_history_entries (default 100) and admin.max_history_bytes (default 10485760 / 10 MiB). Follow the Git-managed configuration file practice (keeping secrets and bypass keys in environment variables referenced via ${VAR}) so that persisted snapshots never write resolved secrets to disk. Changes made through the Admin API or WebUI screens stay in the in-memory history only; the History page merges persisted file-reload snapshots and in-memory entries in descending version order, and history is node-local by design.
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:
- Both the quick Enable/Disable toggle and the full edit modal (enabled, per-category thresholds, timeout override, on-error override) call
PUT /admin/guardrails/providers/{name}. - Adding or deleting providers stays in the
guardrailssection on the Configuration page, and the API key referenced byapi_key_envis displayed masked and read-only.
- Both the quick Enable/Disable toggle and the full edit modal (enabled, per-category thresholds, timeout override, on-error override) call
- 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 input/output stage, and run):- Evaluates every registered provider; the "Show results for" filter only narrows which rows are displayed.
- Each row shows a verdict chip (
pass/flag/block/skipped) or a fallback failure chip (Fail-open/Fail-closed) with the sanitized failure kind and reason when a provider timed out or errored, plus category, score, and a "Raw JSON" fold. - Dry runs do not affect production metrics or the audit log, and the page reports the round-trip time of the whole test call.
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 prompt files referenced by Injection Settings (the default prompt and any backend/model override), loaded via
GET /admin/config/prompts/{path}into a monospace textarea when selected:- Because
GET /admin/config/promptsreturns only config-referenced files (not a directory listing), newly created files stay visible locally until wired into a setting and saved. - Saving and "New file" issue
PUT /admin/config/prompts/{path}after rejecting..and empty path segments client-side; "Reload from disk" callsPOST /admin/config/prompts/reloadbehind a confirmation dialog (no file-delete endpoint exists).
- Because
- Injection Settings (right panel, collapsible) - a form over
global_prompts(PUT /admin/config/global_prompts, which hot-reloads immediately and dispatchesconfig-applied):- Configures the default prompt (inline text or file reference),
prompts_dir, merge strategy (prepend/append/replace), separator (with a live preview matchingGlobalPromptConfig::merge_prompts), and per-backend or per-model overrides. - Rejected saves surface the server's error message inline.
- Configures the default prompt (inline text or file reference),
- 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 shows and manages 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. Each card is independently capability-gated and polls every 15 seconds under the shared header auto-refresh control. ACP and AppProxy remain read-only here; the Continuum Hub card additionally offers privileged router-side enrollment.
- Continuum Hub (shown when
features.control_planeis compiled) - readsGET /admin/control-plane/statusand submits enrollment throughPOST /admin/control-plane/enrollunder Admin authentication:- Displays enrollment status (
enrolled,rejected,pending_restart), sanitized Hub host, heartbeat freshness, policy-sync state, usage queue, and batch occupancy. - Accepts a write-only single-use token (cleared after every attempt), optional Hub URL and router name, and explicit replacement consent (required to replace a healthy credential, while a rejected credential can be recovered in place).
- Successful enrollment persists the owner-only router credential and prompts for a restart (
control_plane.enabledremains a source-config setting).
- Displays enrollment status (
- Hub policy (shown under the Continuum Hub card while the agent is enabled) - a read-only view of
GET /admin/control-plane/policyshowing the active policy authored in the Hub:- Displays revision, tier-policy digest, apply time, key counts, tier limits, substitution rules, equivalence classes, optimization policy, routing windows, and model-budget pools.
- Shows "No policy synced yet" before the first sync, "Hub unreachable, showing the last known policy" when heartbeats fail or go stale, error codes for refused revisions, and any unenforced statement on the applied revision (such as a guardrail rule while the guardrail service is down).
- 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.
Languages and translation¶
The WebUI ships English and Korean message catalogs and follows the browser or an explicit choice. The language is resolved in this order:
localStorage.webui_lang, written by the language switch in the sidebar.- The browser locale (
navigator.languages), when no preference is stored. - English, when neither matches.
Switching applies immediately without a reload. Page titles, dates, and numbers re-render for the selected locale, and Intl formats the dates and numbers. Backend names, model IDs, configuration keys, and API error bodies stay exactly as the server sends them.
The catalogs live in src/webui/assets/i18n/en.json and ko.json. Both must carry the same key set and the same {placeholder} names. scripts/check-webui-i18n.py enforces that in CI and also fails when a source file references a key the catalog does not define. At runtime a missing key falls back to English and logs one console warning.
If the catalogs themselves fail to load, the interface stays closed and shows an error panel with the failure detail and a retry button, instead of painting raw message keys.
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). Missingadmin.authfails closed. Configure a realadmin.auth.methodfor any router reachable outside a trusted network. Explicitnoneis an isolated-development opt-in and the WebUI keeps 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.3.3) 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: