feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1)

Biggest PR of the v0.7.x GUI rework arc. Replaces the single-select /events
filter row with a multi-select, URL-addressable filtering surface.

- Search: full-width box, debounced 300ms, server-side ILIKE over the inner
  adapter payload (covers the derived subject + location); parameterized with
  LIKE wildcards escaped (ESCAPE '\'). Injection-safe.
- Adapter / Category / Event Type / Severity: multi-select chip-pickers (shared
  _chip_picker.html macro). Adapter is grouped by domain with color swatches and
  an in-panel search. Backend uses `= ANY(...)`. URL state is comma-separated.
- Event Type is derived as split_part(category,'.',1) (no event_type column yet;
  a stand-in until the v0.8 canonical schema). Severity maps labels to the
  numeric scale (4=critical..1=low, 0/NULL=unknown).
- Time: preset dropdown (15m/1h/6h/24h/7d/active/all) + custom from/to range,
  encoded in a single `time` token. GUI defaults to last_24h; events.json keeps
  its single-value adapter/since/until contract (no default).
- Active pills: server-rendered from parsed state, updated out-of-band on each
  HTMX swap; each x clears that filter and re-submits.
- URL state persistence: every filter in the query string; /events/rows sets
  HX-Push-Url to the /events?... full-page URL so bookmarking/back-forward work.

Filter options are rendered server-side at page load (DISTINCT category +
split_part, registry adapters, severity enum) -- no new AJAX endpoints.

Vanilla JS + HTMX (no framework added). CSS is functional-only; visual polish
is deferred to a later pass per the rework plan.

Adds TestEventsFiltering (24 tests: multi-value parse, ILIKE injection safety,
time-preset resolution with injected clock, severity/NULL handling, active-pill
descriptors, URL round-trip). Updates four TestEventsFeedFrontend assertions to
the new filter_state/adapters contract.

Full suite: 658 passed, 1 skipped (central and unprivileged zvx). No adapter
base class change -> central-gui restart only (no supervisor restart).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-25 00:58:38 +00:00
commit 380cde31f8
7 changed files with 785 additions and 141 deletions

View file

@ -104,7 +104,8 @@ class TestEventsFeedFrontendAuthenticated:
assert result.status_code == 200
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
assert context["filter_values"]["adapter"] == "nws"
# v0.7.1: adapter is now a multi-select; single value parses to a 1-list.
assert context["filter_state"]["adapters"] == ["nws"]
@pytest.mark.asyncio
async def test_events_since_until_filter(self):
@ -154,8 +155,9 @@ class TestEventsFeedFrontendAuthenticated:
mock_templates.TemplateResponse.assert_called_once()
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
context = call_kwargs.get("context", call_kwargs)
assert context["filter_values"]["since"] == "2026-05-17T00:00:00"
assert context["filter_values"]["until"] == "2026-05-17T12:00:00"
# v0.7.1: the GUI uses time presets, but legacy since/until are still
# honored (JSON API + bookmarks); they parse without error and apply.
assert context["filter_error"] is None
@pytest.mark.asyncio
async def test_events_region_filter(self):
@ -207,10 +209,10 @@ class TestEventsFeedFrontendAuthenticated:
mock_templates.TemplateResponse.assert_called_once()
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
context = call_kwargs.get("context", call_kwargs)
assert context["filter_values"]["region_north"] == "49.5"
assert context["filter_values"]["region_south"] == "31"
assert context["filter_values"]["region_east"] == "-102"
assert context["filter_values"]["region_west"] == "-124.5"
assert context["filter_state"]["region_north"] == "49.5"
assert context["filter_state"]["region_south"] == "31"
assert context["filter_state"]["region_east"] == "-102"
assert context["filter_state"]["region_west"] == "-124.5"
@pytest.mark.asyncio
async def test_events_partial_region_shows_error_banner(self):
@ -772,9 +774,11 @@ class TestRegistryDrivenAdapterFilter:
# The list is exactly the registry, sorted by name (stable), no extras.
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
# Each entry carries name + display_name straight from the adapter class.
# Each entry carries name + display_name (v0.7.1 adds a positional color).
by_name = {a["name"]: a for a in context["adapters"]}
for cls in registry.values():
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
assert by_name[cls.name]["display_name"] == cls.display_name
assert by_name[cls.name]["color"].startswith("#")
class TestPerAdapterRowPartials: