Skip to content

waf: recent sampled match events on the metrics page#330

Open
acoshift wants to merge 2 commits into
mainfrom
waf-events
Open

waf: recent sampled match events on the metrics page#330
acoshift wants to merge 2 commits into
mainfrom
waf-events

Conversation

@acoshift

@acoshift acoshift commented Jul 11, 2026

Copy link
Copy Markdown
Member

Implements the console workstream of SPEC-waf-events.md (§G, step 5 in the §J rollout table): the /waf/metrics?project=&location= page gains a Recent events panel under the match chart — recent sampled WAF match events served by the new waf.events RPC.

What

  • Table (table is-variant-compact): time (relative, absolute on hover), action badge (block=danger, log=neutral, allow=success), rule (short id linked to its row on /waf/manage, tooltip = description from the already-loaded waf.get), IP (mono), country name, method, host, path (mono, truncated, full value in title).
  • Filters: action (all/log/allow/block) + rule (from waf.get's current rules), both server-side — they map 1:1 onto waf.events params — and mirrored into the query string (writable-$derived URL-params pattern, admin#18) so a filtered view is shareable. A hand-edited ?ruleId= naming a deleted rule passes straight through to the RPC and stays visible as <id> (deleted) in the select.
  • Deleted-rule fallback: events outlive rules by up to 3 days (and waf.set regenerates unknown ids), so an event's rule id may match nothing in waf.get — such ids render unlinked with a "rule no longer exists" tooltip, never a dead link.
  • Pagination: keyset — "Load more" sends before = next, page size 50; the server sets next only on an exactly-full page.
  • Sampling caption (verbatim per spec §G): "Sampled — a bounded number of events per firewall (up to 60/min per ingress instance) is kept for 3 days. Counts in the chart above are exact."
  • Fetch lifecycle: client-side after hydration, keyed on (project, location) via the params-keyed $effect pattern (console#303) — ?location=A → ?location=B reuses this component without a remount and must refetch. Filter/range replaceState writes don't re-trigger it; stale in-flight responses are discarded via a token bump.
  • Failure handling: a rejected fetch (offline, connection reset) or an error envelope renders an error row with a single retry affordance — never the "No events in the last 3 days." empty state, which would be a false claim about the data. A failed "Load more" keeps the loaded pages, suppresses the Load more button while the error shows, and "Try again" re-issues the cursor fetch instead of discarding pages.

Also: waf/manage rule rows gain id="waf-rule-<id>" anchor targets for the event-table links; src/types/api.d.ts mirrors the waf.events wire types (request/result/WafEvent); the dev mock (bun dev:mock) seeds ~3 days of events — RFC 5737 documentation IPs only — and replicates the server's exact filter/cursor semantics, including a deleted-rule id.

Screenshots

Generated via the Playwright harness against bun dev:mock (documentation IPs only). The seed data includes old-block-php, a rule that no longer exists in the zone — visible in the panel as the muted, unlinked id.

The metrics page with the new panel in context (dark):

metrics page with recent events, dark

Panel close-up (light) — badges, deleted-rule fallback, country names, truncated mono host/path:

recent events panel, light

Filtered to Block via the server-side action filter (?action=block reflected in the URL), dark:

recent events filtered to block, dark

Test evidence

tests/waf-events.spec.js — 10 tests against the mock server:

  • render: rows, badges, country names, rule links + tooltips, sampling caption
  • deleted-rule id renders unlinked with tooltip
  • empty state
  • hand-edited ?ruleId= passthrough + "(deleted)" select option
  • action filter refetches server-side and lands in the URL
  • rule filter refetches with the short rule id
  • keyset cursor: full page → Load more sends before = next, appends, button disappears on exhaustion
  • ?location=A → ?location=B same-component SPA navigation refetches (document reuse asserted via a window marker)
  • fetch failure shows the error row — never the empty state — and Try again recovers
  • failed Load more keeps loaded pages, offers one retry affordance, retry re-issues the same cursor

Full local verification: bun lint 0 errors · bun check 0 errors · full Playwright suite 341 passed (bun run test).

Rollout (SPEC §J — every step inert alone)

This PR is step 5 of 7. Safe order: apiserver migration → apiserver binary → parapet image + WAF_EVENTS_TOKEN Secret → collector envs → console/docs.

  1. parapet-ingress-controller — wafevent ring + bearer-authed :9188 cursor endpoint + headless Service (feat(waf): sampled match-event ring + direct push to the deploys-app collector moonrhythm/parapet-ingress-controller#182); endpoint off until both envs set, inert until polled
  2. api — Collector.SetWAFEvents + WAF.Events types; tag after merge (this repo consumes the API only via the hand-written type mirror — the api re-pin gate applies to the Go consumers, not console)
  3. apiserver — waf_events DDL (apply migration before binary, house rule) + collector.setWAFEvents + waf.events
  4. collector — syncWAFEvents; enabled only when waf_events_target + waf_events_token are set (same Secret value as the controller), so the binary can ship early
  5. console (this PR)merge gate: step 3 deployed, otherwise the panel shows the error row in prod. Until events flow it degrades to the empty state; rollback = revert, no data to unwind
  6. mcp / CLI, 7. docs — follow separately

Notes

  • Expected merge conflicts: waf-test and waf-ip-lists are open on this repo and also touch src/lib/server/mock.ts, src/types/api.d.ts, and waf/manage/+page.svelte (adjacent additions — trivial resolution; whichever lands second rebases).
  • Open question for the spec author: this table fetches once per (project, location) with no periodic refresh, while the chart above self-refreshes every 60s on short ranges. Spec §G specifies the one-shot load and §A's non-goals exclude live tail; joining the 60s tick would clobber a paginated view mid-inspection, so this PR keeps fetch-once (filters / Try again / navigation all refetch). If "poll-refresh table" in §A meant periodic re-poll, that's a small follow-up.
  • PII: client IPs are display-only; attacker-chosen strings (method/host/path) are Svelte-escaped and truncated with the full value in title. Mock and tests use RFC 5737 documentation IPs exclusively.

@deploys-app

deploys-app Bot commented Jul 11, 2026

Copy link
Copy Markdown

Preview console-pr-330

URL: https://console-pr-330-606515731026706458.rcf2.deploys.app

Image: registry.deploys.app/deploys-app/console@sha256:c2339adee67bea602432e319f18f27f13bf8f2249df8268f890fe79758e9c239

Commit: 0150ca8

updated 2026-07-14T03:41:47Z

Adds the console leg of SPEC-waf-events: a Recent events section on
/waf/metrics backed by the new waf.events RPC — newest-first table
(time, IP, country, method, host, path, rule, action badge), rule +
action filters mirrored into the query string (?ruleId=&action=),
keyset load-more pagination (before = result.next, page size 50), and
the permanent per-instance sampling caption.

Deleted-rule fallback: an event's rule id may match nothing in waf.get
(events outlive rules by up to 3 days and waf.set regenerates unknown
ids) — such ids render as plain unlinked mono text with a 'rule no
longer exists' tooltip, never a dead link. Live rule ids link to their
row on /waf/manage via new per-rule row anchors.

Events load client-side after hydration, keyed on (project, location)
like the metrics fetch, with a token guard discarding stale in-flight
pages. Filters map 1:1 onto waf.events params (server-side filtering);
a hand-edited ?ruleId= for a deleted rule stays visible in the rule
select as a synthetic option instead of a blank trigger.

Mock mode grows a waf.events handler with a session-stable seed
(ULID-shaped time-ordered ids, RFC 5737 documentation IPs, a since-
deleted rule id) mirroring the server's cursor semantics; api.d.ts
gains WafEventsRequest/WafEventsResult/WafEvent matching the api-repo
contract; countries.ts gains countryName for the country column.
…on tests

Review follow-ups on the recent-events panel:

- fetchEvents now catches a rejected fetch (offline, connection reset):
  without it the panel asserted "No events in the last 3 days." — a false
  claim about the data — instead of the error row.
- A failed Load more no longer renders two competing affordances: the
  Load more button is suppressed while the error row shows, and Try again
  re-issues the cursor fetch when pages are already loaded instead of
  discarding them.
- Tests: location A -> B same-component navigation refetch (console#303
  bug class), initial-load error row + recovery, and load-more failure
  preserving pages + retrying the cursor.
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.

1 participant