Web Dashboard
An in-browser, largely read-only view of what Wardline is doing right now. Off by default — enable with:
features:
web_ui: true
Then visit http://<listen-addr>/dashboard/. Views are reached from the
sidebar, grouped the same way the sidebar itself groups them:
Overview; Activity, Anomalies, Blocked, Approvals, Federation
(observability); Policy, RBAC, Budget, Job Budget, Cost Budget,
Credentials (configuration); Status, Reload log, Compliance
(system). Several of these — RBAC, Budget, Job/Cost Budget, Approvals,
Compliance — are covered on their own feature page rather than
redescribed here; this page covers Overview, Activity/Anomalies/
Federation, Blocked, Credentials, and Policy/Status, plus the
cross-cutting auth/CSRF posture that applies to all of them. Live views
poll every 2 seconds; Policy and Status are loaded once and reflect
state as of startup / the last poll respectively.
Overview
The first view you land on, deliberately: a single status band answers “is anything wrong right now” before you look at anything else. The band’s state is derived client-side, on every poll tick, from the same data Blocked and Anomalies already show — checked in this fixed order, a real block always outranks a real anomaly:
action-needed(red) — at least one identity is currently under an activeanomaly.auto_block.attention(amber) — no active blocks, but at least one recorded anomaly exists.nominal(green, “All systems nominal”) — neither.
This ordering is intentional, not incidental: a block is Wardline already having taken automated action against real traffic, which is a strictly more urgent signal than an anomaly that was merely logged. A tenant with 500 logged anomalies and zero active blocks still shows amber, not red — volume of anomalies never escalates the band on its own, only the presence of an active block does.
Below the band: a KPI row (request count, deny rate, anomaly count, blocked count — all computed from the exact same buffers the Activity / Anomalies / Blocked views themselves poll, so nothing here can drift out of sync with what those views show), a recent-activity bar chart, a “needs review” summary with a CTA that jumps straight to Anomalies, and a live pulse (requests/sec over the trailing 10 seconds, with a pause/resume toggle).
Chart caveat: without features.postgres_storage, the recent-activity
chart buckets the last N buffered audit events only — the same
bounded, in-memory, resets-on-restart ring buffer (default capacity
1000) that the Activity view itself polls, not a query over the durable
audit trail (audit.output’s JSONL file). On a busy instance that
cycles through the buffer in minutes, the chart shows recent activity,
not a full historical view — do not read it as “today’s total traffic”
once request volume exceeds the buffer’s capacity. With
features.postgres_storage on, this caveat no longer applies: the
Activity view and this chart both read from the same durable,
cluster-wide audit_entries table every replica writes into (see
PostgresWriter.Since) — not a fixed-N in-memory window, and not
per-replica. The chart’s own subtitle states however many events the
current poll actually returned, so it reads correctly either way;
this is that caveat’s source of truth, not a display bug.
Overview polls at the same 2-second cadence as every other live view,
but nothing on it animates on a routine poll tick — only a genuine state
transition (the status band flipping from one severity to another)
triggers motion, and that transition respects
prefers-reduced-motion like every other animated element in this
dashboard.
Activity, Anomalies, Federation
Live-updating tables over the same after-ID polling pattern. Activity
and Anomalies are described in Anomaly detection;
Federation is empty by design on a single-instance deployment (no peers
to correlate with) and 404s on its API when features.federation is
off, same as every other feature-gated dashboard route.
Blocked
A live-updating table of identities currently under a time-bounded
anomaly.auto_block — identity, tenant, reason, and expiry. Each row
carries an Unblock button that clears the block early (DELETE /dashboard/api/anomalies/blocked/{identity}), after a confirm prompt.
Unblock and Credentials’ Revoke below are two of several mutations the
dashboard now exposes (reload, the policy/budget editors, and
approval decisions are the others — see each feature’s own page, and
“CSRF” under Known limitations for the full current list) — they’re
covered together here because they share a distinctive dual-mechanism
auth gate the others don’t; see “Auth requirement for mutations” below
for exactly who can press either one successfully.
Credentials
Revoke only — deliberately not symmetric with issuance or refresh.
POST /credentials/revoke is the one action here: enter an identity,
confirm, and every outstanding and future-until-expiry access token
plus any outstanding refresh token for that identity is invalidated
immediately.
Why revoke-only: /credentials/refresh performs machine-to-machine
token rotation on a caller-supplied refresh_token value — a dashboard
operator has no legitimate reason to hold or exercise another
identity’s refresh token from a browser UI, so this view never exposes
that path at all. There is no issuance UI either: bootstrapping a new
credential requires that identity’s own registration secret (see
Credential issuance), which is not
something that belongs behind an operator-facing screen reachable by
anyone who can load the dashboard. Revocation is the one genuinely
admin-shaped action left — undoing a credential’s standing, not
minting or renewing one — so it is the one this view ships.
Auth requirement for mutations (Blocked’s Unblock, Credentials’ Revoke) — read this before assuming a 403 is a bug
Both mutations are gated more strictly than the rest of the dashboard, and by two different mechanisms depending on which button you press:
- Credentials’ Revoke reuses
/credentials/revoke’s own existing gate, unchanged by this view’s existence: allowed unconditionally from a loopback caller (127.0.0.1/::1); from anywhere else, only whenfeatures.rbacis on and the resolved caller holdscredential:revoke(the built-inadminrole, or a custom role naming that permission — see RBAC). A caller holding onlydashboard:view(e.g. the built-inviewerrole) loads the rest of the dashboard fine and gets a clean403here — that is correct, not a bug, and the view surfaces a specific “You don’t have permission to revoke credentials.” message for exactly that case rather than a generic error. - Blocked’s Unblock has no loopback exception at all — it is gated
purely by the same
credential:revokepermission, checked separately from thedashboard:viewpermission the rest of the Blocked view relies on to render at all. A caller who can see the Blocked table (holdsdashboard:view) but lackscredential:revokegets a clean403clicking Unblock, same posture as Credentials’ Revoke. This means Unblock only works at all whenfeatures.rbacis also on —credential:revokeis an RBAC permission, so withrbacoff there is no authorizer to check it against and the button’s own request gets a generic404, not a403. Verified live:anomaly_detection+web_uiwithrbacoff renders the Blocked table (and the button) fine, but clicking Unblock always 404s. Turnrbacon if you need Unblock to actually work — the table itself, and every other dashboard view, functions the same either way.
A sharp edge worth knowing before you rely on either button:
neither button’s own client-side code attaches a credential of its
own — both rely entirely on whatever the browser sends automatically
with every same-origin request. That is sufficient in two common cases:
you’re loopback (nothing to attach — the loopback exception on
/credentials/revoke needs no identity at all; Blocked’s Unblock still
needs a resolvable identity, but a loopback operator typically already
has one via whichever IdentityAuthenticator is active), or identity is
resolved from a raw X-Wardline-Identity header that a trusted
intermediary (reverse proxy, mesh sidecar) injects on your behalf before
traffic reaches Wardline — browsers don’t send that header themselves,
but an intermediary that does makes every request the browser makes
already carry it, including these two.
When features.credential_issuance is on, identity is a bearer token
(Authorization: Bearer <jwt>), and a plain browser tab has no
built-in mechanism to attach that header to its own requests the way it
automatically attaches cookies. GET /dashboard/login closes this
gap: a browser-native sign-in form (paste the same bootstrap secret
or OIDC ID token POST /credentials/token accepts) that exchanges it
for the exact same access token through the exact same
IssuanceService.Bootstrap path, then delivers it as an httpOnly,
SameSite=Strict session cookie (POST /dashboard/login) instead of a
JSON body — the browser then sends that cookie automatically on every
subsequent request, including these two buttons, the rest of
/dashboard/, and (once features.rbac is also on)
rbacadapter.RequirePermission’s own identity resolution, since all of
these already went through the same IdentityAuthenticator that now
checks the cookie as a fallback whenever no Authorization header is
present. POST /dashboard/logout clears it. Session lifetime is
exactly credential.access_token_ttl_seconds (default 15m) — there is
no refresh-token cookie or silent renewal yet, so re-login when it
expires; see “Known limitations” below.
Policy, Status
Policy shows the active policy backend and raw policy file content as
currently loaded. With features.config_file_watch on, an edit to the
policy file on disk applies automatically (see “Known limitations”
below); otherwise trigger POST /dashboard/api/reload/policy (gated by
config:edit when rbac is on) or restart Wardline to refresh it.
Status shows version, uptime, listen/upstream addresses, and which
feature flags are on.
Security note
The dashboard requires no authentication and every non-mutating route
is read-only by default (unless features.rbac is on — see
RBAC; with it on, every dashboard request must
resolve an identity holding dashboard:view, else 403). Blocked’s
Unblock and Credentials’ Revoke, above, are each independently gated by
credential:revoke rather than the weaker dashboard:view a plain
reader might hold — the same permission the dashboard’s other mutations
(reload, the policy/budget editors, approval decisions) gate behind
config:edit instead, a distinct permission from either of these. No
dashboard mutation can influence policy evaluation, budget accounting,
or how a proxied call is decided going forward except through its own
one narrow, audited, explicitly-permissioned action. The
dashboard shares the exact same listener/port as the proxy itself, so
anyone who can reach Wardline’s proxy port — including every agent
Wardline proxies calls for — can already read full audit reasons and
raw policy source over that identical socket; binding to localhost
does not change this. This is why web_ui defaults to off.
Known limitations
- Without
features.postgres_storage, the recent-activity chart on Overview reflects the bounded audit ring buffer, not the durable audit trail — see the chart caveat above. Withpostgres_storageon, it reads the same durable, cluster-wide table the Activity view does. GET/POST /dashboard/loginandPOST /dashboard/logoutnow deliver a bearer token to the browser (features.credential_issuanceon) — see “Auth requirement for mutations” above. Session lifetime is exactly the access token’s own TTL (credential.access_token_ttl_seconds, default 15m): no refresh-token cookie or silent renewal yet, so an expired session requires signing in again through/dashboard/login, not a transparent background refresh — a deliberately bounded scope for this cycle (a real, separate feature: rotating a refresh-token cookie plus an auto-refresh flow), not silently incomplete.- Federation’s correlated-alerts view is now tenant-scoped like every
other dashboard view (see Federation’s known
limitations for what’s still instance-scoped: each Wardline instance
shows its own
Correlator’s state, not a fleet-wide merged view). - Policy, budget, and RBAC changes auto-apply on file edit when
features.config_file_watchis on — anfsnotifywatcher on the main config file pluspolicy_file/rbac.config_filecalls the same reload closuresPOST /dashboard/api/reload/{domain}does, debounced (300ms) so one logical save doesn’t trigger several reloads, and robust to an atomic replace-by-rename save (vim and most editors’ default) — it watches the enclosing directory, not the file’s own inode, which a rename-over-original save would otherwise orphan. Off by default (a new capability beyond the v0.1 baseline); without it, trigger a reload manually withPOST /dashboard/api/reload/{domain}(gated by theconfig:editpermission whenrbacis on) after editing, or restart Wardline. - CSRF: deliberately no separate anti-CSRF token. The session
cookie is
SameSite=Strict(see “Auth requirement for mutations” above), which OWASP’s current guidance treats as a primary, sufficient CSRF mitigation for a session with no cross-site-linking use case to preserve — a cross-site page cannot make the browser attach this cookie to a request at all, regardless of method. Every mutating endpoint (/dashboard/api/reload/*, the policy/budget editors,/dashboard/api/anomalies/blocked/*unblock,/dashboard/api/approvals/*decisions) additionally requiresPOST/DELETE(never triggerable by a bare link, image tag, or prefetch) and its own explicit authorization check independent of the cookie’s mere presence.