ADR-0120: Collapsible, HTMX-Live Bot Shield & Analytics Console
Status: Accepted
Date: 2026-07-08
Author: @johalputt
Relates to: ADR-0036 (strict admin CSP), ADR-0107 (self-hosted HTMX), ADR-0111 (VayuShield + VayuAnalytics), ADR-0117 (off-request dashboard computation)
Context
The Bot Shield & Analytics console (/os/shield) had grown into a long, dense
page: a status hero, a settings form, network-hardening notes, bot-signature
stats, a review queue, a recent-blocks list, and several engagement-analytics
cards, all expanded at once. Two operator asks followed:
- Make it calm — every section should be collapsed by default and expand only on click, keeping the top (status) always visible, using the same lightweight mechanism the "Network hardening" panel already used, with no heavy code.
- Make it live — each section should update on its own without reloading the whole page, and HTMX should be used wherever else it fits.
The hard constraint is the strict admin CSP (ADR-0036): script-src 'self',
style-src 'self', no inline scripts or styles. Any solution must add no new
client JavaScript and no inline style.
Decision
Use two mechanisms already present in the codebase, so nothing new ships to the browser:
1. Native <details>/<summary> for collapse (zero JS, zero new CSS behaviour)
Every section below the status hero is a <details class="card"> with a
<summary class="card-title vs-summary">, collapsed by default and expanded on
click — exactly the pattern the Network-hardening panel already used, reusing
the existing .vs-summary styling. The page header and the live status hero
stay outside any <details>, always visible. This is pure HTML + the browser's
built-in disclosure behaviour; the only CSS added is a one-line rule to
right-align the per-section refresh control.
2. HTMX for per-section live updates (HTMX already loaded, CSP-safe)
HTMX is already vendored and loaded on every admin page (ADR-0107), so partial
updates cost no new script. Each section's body is wrapped in a container with a
stable id (vs-body-hero, vs-body-protection, vs-body-signatures,
vs-body-queue, vs-body-blocks, vs-body-engagement) whose contents are
produced by a dedicated per-section body builder. A single admin-gated fragment
endpoint, GET /os/shield/section/{name}, returns just one section's body HTML
(no page layout), and the same builder feeds both the initial full-page render
and the fragment — one source of truth, no drift.
- Status hero polls itself (
hx-trigger="every 10s") and also listens for avs-refreshevent, so live metrics stay current on their own. - Signatures, Review queue, Recent blocks, Engagement each carry a small
↻ Refresh button (
hx-getthe section,hx-swap="innerHTML"into its own body id). - Saving settings no longer returns
HX-Refresh(a whole-page reload); the handler now returnsHX-Trigger: vs-refresh, which the hero and the settings body listen for — so only those two reload in place, showing the applied state. - Confirm/Dismiss in the review queue still deletes the row on a 2xx
(
hx-swap="delete") and additionally returnsHX-Trigger: vs-refresh-sig, so the signature counts and queue body refresh in place (the "Pending review" figure stays accurate).
Fragments are written by a small writeOSFragment helper that sets the admin
no-cache/noindex headers but, unlike writeOSHTML, does not re-issue a CSRF
cookie — the frequent hero poll should not churn the cookie. CSRF for the write
actions (settings/verify/dismiss) is unchanged: the double-submit cookie is set
on the full-page load and mirrored into the request header by the shell's
existing htmx:configRequest hook.
Consequences
- The console opens tidy: only the header + live status hero are visible; the operator expands only what they need.
- Sections update independently and cheaply: the hero self-refreshes, and every data section can be refreshed (or auto-refreshes after a relevant action) without a full-page reload — matching the responsiveness the operator asked for.
- The heavy engagement analytics remain served from the off-request background cache (ADR-0117), so a section refresh can never run a blocking scan on the request path — no reintroduced 502 risk.
- No new client JavaScript and no inline style were added; the strict admin CSP (ADR-0036) is fully preserved. The change is additive and presentational plus transport — the underlying settings, signature, and analytics behaviour is unchanged.