diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index fe697ae..c8e5bd7 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -2896,7 +2896,8 @@ def _build_pagination(total: int | None, offset: int, limit: int) -> dict: def _parse_events_params(params, default_time: str | None = None, - default_offset: int | None = None) -> tuple[dict | None, str | None]: + default_offset: int | None = None, + default_show_removed: bool = True) -> tuple[dict | None, str | None]: """ Parse and validate events query parameters. @@ -3040,8 +3041,17 @@ def _parse_events_params(params, default_time: str | None = None, except Exception: return None, "Invalid cursor" + # Tombstone visibility: the GUI default-hides `*.removed` events; the JSON + # API default-includes them. An explicit ?show_removed= overrides the default. + sr = params.get("show_removed") + if sr is not None and sr != "": + show_removed = sr.lower() in ("1", "true", "on") + else: + show_removed = default_show_removed + return { "limit": limit, + "show_removed": show_removed, "offset": offset, "q": q, "adapters": adapters, @@ -3172,6 +3182,11 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: query_params.append(cursor_id) param_idx += 2 + # GUI hides tombstones by default (events.json includes them). Static + # literal pattern, no bound param; NULL-category rows are kept. + if not parsed_params.get("show_removed", True): + conditions.append("(category IS NULL OR category NOT LIKE '%.removed')") + where_clause = "" if conditions: where_clause = "WHERE " + " AND ".join(conditions) @@ -3361,7 +3376,8 @@ async def _events_query(request: Request, data_class: str): which _fetch_events applies as an `adapter = ANY(...)` condition. """ params = request.query_params - parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0) + parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0, + default_show_removed=False) class_adapters = _class_adapter_names(data_class) if parsed is not None: parsed["class_adapters"] = class_adapters @@ -3392,6 +3408,7 @@ def _events_filter_state(parsed: dict | None, params) -> dict: "region_west": params.get("region_west", ""), "map_filter": pstate.get("map_filter", False), "limit": str(pstate.get("limit", 50)), + "show_removed": pstate.get("show_removed", False), } diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html index 73bcf1e..7532b0a 100644 --- a/src/central/gui/templates/events_list.html +++ b/src/central/gui/templates/events_list.html @@ -68,8 +68,16 @@ map-controls checkbox below syncs + enables it. #} + {# Tombstone visibility: enabled only when "Show removed" is on, so it's + omitted from the URL by default (GUI default-hides *.removed events). #} +
+ Clear all
@@ -714,6 +722,18 @@ submitForm(); } }); + + // "Show removed" toggle: enable the hidden input only when checked (so it's + // omitted from the URL by default), then re-query. The rows fragment's + // HX-Push-Url preserves the choice in a shared link. + var showRemovedToggle = document.getElementById("show-removed-toggle"); + var showRemovedHidden = document.getElementById("filter-show_removed"); + if (showRemovedToggle && showRemovedHidden) { + showRemovedToggle.addEventListener("change", function () { + showRemovedHidden.disabled = !showRemovedToggle.checked; + submitForm(); + }); + } })(); diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index de8ed66..ffdfcb2 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -1227,3 +1227,38 @@ class TestEventsJsonSubject: def test_missing_source_fields_yields_none(self): """An event lacking its adapter's source fields derives no subject.""" assert _derive_subject(_subject_event("usgs_quake", {})) is None + + +class TestShowRemovedToggle: + """v0.9.11 — tombstone visibility wiring. filter_state.show_removed drives + the 'Show removed' checkbox in events_list.html; defaults to hidden.""" + + def _pool(self): + conn = AsyncMock() + conn.fetch.return_value = [] + conn.fetchrow.return_value = { + "map_tile_url": "https://t/{z}/{x}/{y}.png", "map_attribution": "OSM"} + pool = MagicMock() + pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn) + pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None) + return pool + + async def _filter_state(self, query_params): + req = MagicMock() + req.state.operator = MagicMock(id=1, username="admin") + req.state.csrf_token = "t" + req.query_params = query_params + templates = MagicMock() + templates.TemplateResponse.return_value = MagicMock(status_code=200) + with patch("central.gui.routes._get_templates", return_value=templates): + with patch("central.gui.routes.get_pool", return_value=self._pool()): + await events_list(req) + return templates.TemplateResponse.call_args.kwargs["context"]["filter_state"] + + @pytest.mark.asyncio + async def test_default_hides_removed(self): + assert (await self._filter_state({}))["show_removed"] is False + + @pytest.mark.asyncio + async def test_show_removed_true_reflected(self): + assert (await self._filter_state({"show_removed": "true"}))["show_removed"] is True diff --git a/tests/test_events_filtering.py b/tests/test_events_filtering.py index 69dcabf..fa7ae4e 100644 --- a/tests/test_events_filtering.py +++ b/tests/test_events_filtering.py @@ -251,3 +251,45 @@ async def test_select_includes_severity_column(): parsed, _ = routes._parse_events_params({"time": "all"}) cap = await _capture(parsed) assert "severity" in cap["query"] + + +# --- show_removed / tombstone visibility (v0.9.11) -------------------------- + +@pytest.mark.asyncio +async def test_gui_default_excludes_removed(): + """GUI passes default_show_removed=False -> *.removed hidden by default.""" + parsed, _ = routes._parse_events_params( + {"time": "all"}, default_time="last_24h", default_show_removed=False) + assert parsed["show_removed"] is False + cap = await _capture(parsed) + assert "category NOT LIKE '%.removed'" in cap["query"] + + +@pytest.mark.asyncio +async def test_show_removed_true_includes_all(): + """?show_removed=true -> no tombstone exclusion in the query.""" + parsed, _ = routes._parse_events_params( + {"time": "all", "show_removed": "true"}, default_show_removed=False) + assert parsed["show_removed"] is True + cap = await _capture(parsed) + assert ".removed" not in cap["query"] + + +@pytest.mark.asyncio +async def test_json_api_includes_removed_by_default(): + """events.json calls the parser bare (default True) -> API output unchanged.""" + parsed, _ = routes._parse_events_params({"time": "all"}) + assert parsed["show_removed"] is True + cap = await _capture(parsed) + assert ".removed" not in cap["query"] + + +@pytest.mark.asyncio +async def test_show_removed_composes_with_other_filters(): + """The tombstone exclusion coexists with adapter/time filters.""" + parsed, _ = routes._parse_events_params( + {"adapter": "wfigs_perimeters", "time": "all"}, default_show_removed=False) + cap = await _capture(parsed) + assert "adapter = ANY($" in cap["query"] + assert "category NOT LIKE '%.removed'" in cap["query"] + assert ["wfigs_perimeters"] in cap["params"]