Skip to content

feat(dashboard): rebuild the dashboard as a security operations screen - #287

Open
bihius wants to merge 3 commits into
mainfrom
feat/dashboard-redesign
Open

feat(dashboard): rebuild the dashboard as a security operations screen#287
bihius wants to merge 3 commits into
mainfrom
feat/dashboard-redesign

Conversation

@bihius

@bihius bihius commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Why

The dashboard was still the bootstrap placeholder: five all-time counters, a
hardcoded "Recent activity" list narrating the frontend's own build progress,
and a checksum panel. It answered none of the questions an operator actually
opens a WAF console with — is something attacking me right now, what is being
blocked, is my config live?
— and roughly 40% of the screen below the fold was
empty.

It was also expensive for what it showed: the five counters cost five requests,
two of which fetched entire vhost and policy lists client-side just to call
.length on them, and two of which were GET /logs?page_size=1 reads of
total. There was no aggregation endpoint anywhere in the backend.

Backend — new /stats endpoints

app/routers/stats.py + app/services/stats_service.py + app/schemas/stats.py,
all behind get_current_user:

Endpoint Returns
GET /stats/overview KPI counters for a 1h/24h/7d/30d window, each compared against the immediately preceding window of the same length
GET /stats/timeseries Dense allow/deny/monitor buckets (12–30 bars per window); empty intervals come back as explicit zeros
GET /stats/top Most-triggered rules, most-denied source IPs and most-targeted vhosts, with ban state attached for admins

Notes on the implementation:

  • Overview is one grouped scan covering both window halves and all four
    counters, rather than eight COUNT(*) queries. Every grouped column
    (event_at, action, severity, source_ip, rule_id, vhost) is
    already indexed.
  • Bucketing groups on an integer bucket index rather than a formatted date
    string, so 5-minute and 6-hour buckets share one code path. The dialect
    branch (strftime / extract epoch) is isolated in one helper so a future
    Postgres move doesn't mean rewriting the grouping logic.
  • delta_pct is null when the previous window was empty — a dashboard should
    never render "+∞%". The UI shows "new" instead.
  • The banned-IP count is the only figure that comes from HAProxy stick-tables
    rather than the database, so it is the only one that can fail independently.
    It degrades to null instead of failing the request: an unreachable stats
    socket must not blank out database-backed metrics.
  • The 30d ceiling matches the default log retention — a longer window could
    only ever render purged, empty buckets.

Recent blocks reuse GET /logs?action=deny&page_size=8; no new endpoint needed.

Frontend

  • Time window in the URL (?window=7d) drives every section at once.
    Implemented as a real tablist, so arrow keys move between ranges and the view
    can be linked to.
  • Hand-rolled SVG stacked bar chart. No charting dependency: the shape is
    simple, and drawing it directly keeps every colour on the existing semantic
    tokens, so both emerald and frost work without a parallel styling system.
    The same numbers are mirrored in a visually hidden <table>, so nothing in
    the chart is conveyed by colour alone.
  • KPI tiles show window-over-window deltas. Direction is carried by a glyph
    and by wording, not by colour — the tiles still read correctly in greyscale.
    A rise in blocked traffic is treated as a warning; the same shape of change
    on allowed traffic is neutral.
  • Top rules / top source IPs deep-link into the pre-filtered log viewer.
    LogsPage learned to hydrate its filters from the query string to make that
    work (filtersFromSearchParams, with the enum and numeric values validated
    rather than trusted).
  • System status folds in pending-config detection: comparing the generated
    checksum against the one HAProxy last reloaded answers "is what I edited
    actually live?".
  • Every section owns its loading, empty and error state. One failing
    endpoint no longer decides what the rest of the page shows, and an all-zero
    chart is replaced by an empty state with a route into vhost management.
  • Auto-refresh every 30s, paused while the tab is hidden.

Visual/token changes

Additive only — existing --primary/--background are untouched, so the other
four screens are unaffected.

  • Font fix: --font-sans asked for Inter while index.html loaded DM Sans
    and IBM Plex Sans, so the app had been rendering in a system fallback the
    whole time. Now DM Sans, plus IBM Plex Mono for figures, checksums and IPs.
  • Chart series tokens (--color-chart-allow/monitor/deny/grid) so action →
    colour means one thing everywhere.
  • A prefers-reduced-motion block, which the stylesheet previously had none of.

use-dashboard-stats.ts is deleted; listAllVHosts/listAllPolicies are kept
because other screens still use them.

Testing

  • uv run pytest --cov=app — 713 passed, 6 skipped. stats_service.py at 96%.
    New: tests/unit/test_stats_service.py, tests/integration/test_stats_router.py
    (window maths, dense bucket filling, null delta without a baseline, 401/422,
    banned_ips hidden from viewers, and an unreachable Runtime API degrading to
    null rather than failing the response).
  • uv run mypy app/ and uv run ruff check app/ — clean.
  • pnpm type-check, pnpm lint, pnpm test — clean, 165 tests. The old
    DashboardPage.test.tsx asserted on exactly five loading skeletons and mocked
    four API modules, so it was rewritten; new tests cover the chart primitive,
    the formatters and the log URL-filter parsing.
  • Smoke-tested against a seeded in-process app: all three endpoints return
    sensible payloads, window=99d → 422, no token → 401.

haproxy -c was not run — HAProxy is not installed on this machine, and no
HAProxy config is touched by this change.

Copilot AI review requested due to automatic review settings July 29, 2026 05:52

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

bihius added 2 commits July 30, 2026 09:29
Adds GET /stats/overview, /stats/timeseries and /stats/top so the dashboard
can be driven by server-side aggregation instead of counting client-side.

- overview: KPI counters for a 1h/24h/7d/30d window, each compared against
  the immediately preceding window of the same length. Replaces the five
  round-trips the frontend used to make, two of which fetched entire vhost
  and policy lists just to count them.
- timeseries: dense allow/deny/monitor buckets (12-30 bars per window) with
  empty intervals returned as explicit zeros.
- top: most frequently triggered rules, denied source IPs and targeted
  vhosts, with ban state attached for admins.

The banned-IP count reads HAProxy stick-tables and is the only part that can
fail independently, so it degrades to null rather than failing the request -
an unreachable stats socket must not blank out database-backed metrics.
Replaces the bootstrap placeholder - five all-time counters and a hardcoded
"Recent activity" list describing the frontend's own build progress - with a
screen driven by the new /stats endpoints.

- A 1h/24h/7d/30d window, kept in the URL so a view can be shared, drives
  every section at once.
- Hand-rolled SVG stacked bar chart of allow/monitor/deny over time. No
  charting dependency: the shape is simple and drawing it directly keeps
  every colour on the existing semantic tokens, so both themes work. The
  same numbers are mirrored in a visually hidden table, so nothing in the
  chart is conveyed by colour alone.
- KPI tiles now show window-over-window deltas, with direction carried by a
  glyph and wording rather than colour.
- Top triggered rules and top denied source IPs, each deep-linking into the
  pre-filtered log viewer; LogsPage learned to hydrate its filters from the
  query string to make that work.
- System status folds in pending-config detection: comparing the generated
  checksum against the one HAProxy last reloaded answers "is what I edited
  actually live?".
- Every section owns its loading, empty and error state, so one failing
  endpoint no longer decides what the rest of the page shows.

Also fixes the font stack: --font-sans asked for Inter while index.html
loaded DM Sans, so the app had been rendering in a system fallback.
@bihius
bihius force-pushed the feat/dashboard-redesign branch from 574202c to 446e904 Compare July 30, 2026 07:29
Arrow keys moved focus synchronously inside the key handler, before React
had committed the selection change. Because the selected window lives in
the URL, that commit re-renders the whole screen, so anything disturbing
focus mid-render left the roving tabindex pointing at a blurred element —
every later arrow key then landed on the document and did nothing.

Move the focus into an effect keyed on the selection so it runs after the
commit, guarded by a containment check so a refresh elsewhere on the page
cannot pull focus into the tablist.

Adds TimeRangeTabs tests that walk the full control rather than asserting
a single move, plus coverage for the policies list edit modal, which had
none and was reported as broken from the field.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants