Merge pull request #54 from zvx-echo6/feat/events-filtering

feat(filtering): chip-picker filters, search, time presets, active pills (v0.7.1)
This commit is contained in:
malice 2026-05-24 18:59:22 -06:00 committed by GitHub
commit b3b61d8f44
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 785 additions and 141 deletions

View file

@ -5,8 +5,9 @@ import html
import json
import logging
import re
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from typing import Any
from urllib.parse import urlencode
logger = logging.getLogger("central.gui.routes")
@ -2616,10 +2617,159 @@ class EventsQueryResult:
self.error = error
def _parse_events_params(params) -> tuple[dict | None, str | None]:
# --- v0.7.1 filtering: shared constants + pure helpers ----------------------
# Map a severity label (UI) to the numeric severity values it covers. Numeric
# scale per nws.SEVERITY_MAP (Extreme=4..Minor=1, Unknown=None). "unknown"
# also covers NULL and sev 0 (no assessment) -- handled in the query builder.
SEVERITY_LABELS: dict[str, list[int]] = {
"critical": [4], "high": [3], "moderate": [2], "low": [1], "unknown": [0],
}
SEVERITY_ORDER = ["critical", "high", "moderate", "low", "unknown"]
TIME_PRESETS: dict[str, timedelta] = {
"last_15m": timedelta(minutes=15),
"last_1h": timedelta(hours=1),
"last_6h": timedelta(hours=6),
"last_24h": timedelta(hours=24),
"last_7d": timedelta(days=7),
}
TIME_PRESET_LABELS = {
"last_15m": "Last 15 min", "last_1h": "Last 1 hour", "last_6h": "Last 6 hours",
"last_24h": "Last 24 hours", "last_7d": "Last 7 days",
"active": "Active now", "all": "All time",
}
DEFAULT_TIME = "last_24h"
# Adapter -> domain grouping for the chip-picker (registry-derived; any adapter
# not listed here falls into "Other"). Group order is the display order.
ADAPTER_GROUPS = {
"Disasters": ["gdacs", "firms", "inciweb", "wfigs_incidents", "wfigs_perimeters"],
"Weather": ["nws"],
"Space": ["swpc_alerts", "swpc_kindex", "swpc_protons"],
"Geophysical": ["usgs_quake", "nwis"],
"Earth Observation": ["eonet"],
}
# Same palette the map legend uses, indexed by sorted-adapter position.
EVENTS_PALETTE = [
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488",
]
def _csv_param(params, name: str) -> list[str]:
"""Parse a comma-separated multi-value query param into a clean list."""
raw = (params.get(name) or "").strip()
return [v.strip() for v in raw.split(",") if v.strip()] if raw else []
def _query_items(params):
"""(key, value) pairs from Starlette QueryParams (multi-valued) or a plain
dict (as used in unit tests)."""
if hasattr(params, "multi_items"):
return params.multi_items()
return list(params.items())
def _ilike_escape(term: str) -> str:
"""Escape LIKE wildcards so user input is matched literally (ESCAPE '\\')."""
return term.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _resolve_time(token: str | None, now: datetime):
"""Resolve a time token to (since, until, active, error).
'all'/None -> no time filter; 'active' -> active-now flag; presets -> since;
'custom:<from_iso>,<to_iso>' -> explicit range. Pure (clock injected).
"""
if token in (None, "", "all"):
return None, None, False, None
if token == "active":
return None, None, True, None
if token in TIME_PRESETS:
return now - TIME_PRESETS[token], None, False, None
if token.startswith("custom:"):
f, _, t = token[len("custom:"):].partition(",")
try:
since = datetime.fromisoformat(f.replace("Z", "+00:00")) if f else None
until = datetime.fromisoformat(t.replace("Z", "+00:00")) if t else None
except ValueError:
return None, None, False, "Invalid datetime in custom time range"
if since and until and since > until:
return None, None, False, "time 'from' must be before 'to'"
return since, until, False, None
return None, None, False, f"Unknown time preset: {token}"
def _adapter_filter_options():
"""Registry-derived (flat, grouped) adapter options for the chip-picker.
flat: [{name, display_name, color}] sorted by name (color by sorted index,
matching the map legend). grouped: [(group_label, [{value,label,color}])].
"""
reg = discover_adapters()
ordered = sorted(reg.values(), key=lambda c: c.name)
color_by_name = {
cls.name: EVENTS_PALETTE[i % len(EVENTS_PALETTE)] for i, cls in enumerate(ordered)
}
flat = [
{"name": cls.name, "display_name": cls.display_name, "color": color_by_name[cls.name]}
for cls in ordered
]
grouped, grouped_names = [], set()
for group_label, names in ADAPTER_GROUPS.items():
items = [
{"value": n, "label": reg[n].display_name, "color": color_by_name[n]}
for n in names if n in reg
]
if items:
grouped.append((group_label, items))
grouped_names.update(items_n["value"] for items_n in items)
leftover = [
{"value": c.name, "label": c.display_name, "color": color_by_name[c.name]}
for c in ordered if c.name not in grouped_names
]
if leftover:
grouped.append(("Other", leftover))
return flat, grouped
def _build_active_pills(parsed: dict, adapter_total: int) -> list[dict]:
"""Server-side active-filter pills (descriptors) from parsed filter state.
Each pill: {"key": <param name to clear>, "label": <human text>}.
"""
pills: list[dict] = []
if parsed.get("q"):
pills.append({"key": "q", "label": f'Search: "{parsed["q"]}"'})
if parsed.get("adapters"):
pills.append({"key": "adapter",
"label": f'Adapters: {len(parsed["adapters"])} of {adapter_total}'})
if parsed.get("categories"):
pills.append({"key": "category", "label": f'Categories: {len(parsed["categories"])}'})
if parsed.get("event_types"):
pills.append({"key": "event_type",
"label": "Event types: " + ", ".join(parsed["event_types"])})
if parsed.get("severities"):
rank = {lbl: i for i, lbl in enumerate(SEVERITY_ORDER)}
labs = sorted(parsed["severities"], key=lambda s: rank.get(s, 99))
pills.append({"key": "severity", "label": "Severity: " + ", ".join(labs)})
token = parsed.get("time_token")
if token and token != "all":
label = "Custom range" if token.startswith("custom:") else TIME_PRESET_LABELS.get(token, token)
pills.append({"key": "time", "label": label})
return pills
def _parse_events_params(params, default_time: str | None = None) -> tuple[dict | None, str | None]:
"""
Parse and validate events query parameters.
Multi-value filters (adapter, category, event_type, severity) are
comma-separated. `time` is a single token (preset / active / all /
custom:from,to); legacy since/until are still honored for the JSON API.
`default_time` (GUI only) supplies the time token when none is given.
Returns:
(parsed_params, error_message)
If error_message is not None, parsed_params is None.
@ -2634,37 +2784,42 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
if limit < 1 or limit > 200:
return None, "limit must be between 1 and 200"
# Parse adapter filter
adapter = params.get("adapter")
if adapter == "":
adapter = None
# Multi-value filters (comma-separated; a single value is just a CSV of
# length 1, preserving the old single-value API). Unknown severity labels
# are ignored rather than erroring.
q = (params.get("q") or "").strip()[:200] or None
adapters = _csv_param(params, "adapter")
categories = _csv_param(params, "category")
event_types = _csv_param(params, "event_type")
severities = [s for s in _csv_param(params, "severity") if s in SEVERITY_LABELS]
# Parse category filter
category = params.get("category")
if category == "":
category = None
# Parse since/until filters
since = None
until = None
since_str = params.get("since")
if since_str:
try:
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
except ValueError:
return None, f"Invalid ISO 8601 datetime for since: {since_str}"
until_str = params.get("until")
if until_str:
try:
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
except ValueError:
return None, f"Invalid ISO 8601 datetime for until: {until_str}"
# Validate since <= until
if since and until and since > until:
return None, "since must be before or equal to until"
# Time: a single token wins; otherwise legacy since/until (JSON API); then
# the GUI-supplied default_time when nothing else is given.
time_token = params.get("time") or None
since = until = None
active = False
if time_token:
since, until, active, terr = _resolve_time(time_token, datetime.now(timezone.utc))
if terr:
return None, terr
else:
since_str = params.get("since")
if since_str:
try:
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
except ValueError:
return None, f"Invalid ISO 8601 datetime for since: {since_str}"
until_str = params.get("until")
if until_str:
try:
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
except ValueError:
return None, f"Invalid ISO 8601 datetime for until: {until_str}"
if since and until and since > until:
return None, "since must be before or equal to until"
if since is None and until is None and default_time:
time_token = default_time
since, until, active, _ = _resolve_time(time_token, datetime.now(timezone.utc))
# Parse region bbox
region_north = params.get("region_north")
@ -2729,10 +2884,15 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
return {
"limit": limit,
"adapter": adapter,
"category": category,
"q": q,
"adapters": adapters,
"categories": categories,
"event_types": event_types,
"severities": severities,
"time_token": time_token,
"since": since,
"until": until,
"active": active,
"bbox": bbox,
"cursor_time": cursor_time,
"cursor_id": cursor_id,
@ -2766,10 +2926,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
pool = get_pool()
limit = parsed_params["limit"]
adapter = parsed_params["adapter"]
category = parsed_params["category"]
q = parsed_params.get("q")
adapters = parsed_params.get("adapters") or []
categories = parsed_params.get("categories") or []
event_types = parsed_params.get("event_types") or []
severities = parsed_params.get("severities") or []
since = parsed_params["since"]
until = parsed_params["until"]
active = parsed_params.get("active", False)
bbox = parsed_params["bbox"]
cursor_time = parsed_params["cursor_time"]
cursor_id = parsed_params["cursor_id"]
@ -2779,16 +2943,43 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
query_params = []
param_idx = 1
if adapter:
conditions.append(f"adapter = ${param_idx}")
query_params.append(adapter)
if q:
# Subject and location are both derived from the inner adapter payload
# (payload->'data'->'data'), so a case-insensitive match over that
# subtree's text covers both. Parameterized + LIKE-wildcards escaped.
conditions.append(f"(payload->'data'->'data')::text ILIKE ${param_idx} ESCAPE '\\'")
query_params.append(f"%{_ilike_escape(q)}%")
param_idx += 1
if category:
conditions.append(f"category = ${param_idx}")
query_params.append(category)
if adapters:
conditions.append(f"adapter = ANY(${param_idx})")
query_params.append(adapters)
param_idx += 1
if categories:
conditions.append(f"category = ANY(${param_idx})")
query_params.append(categories)
param_idx += 1
if event_types:
conditions.append(f"split_part(category, '.', 1) = ANY(${param_idx})")
query_params.append(event_types)
param_idx += 1
if severities:
nums = sorted({n for lbl in severities for n in SEVERITY_LABELS.get(lbl, [])})
if "unknown" in severities:
# sev 0 and NULL both read as "unknown" (no assessment).
conditions.append(f"(severity = ANY(${param_idx}) OR severity IS NULL)")
else:
conditions.append(f"severity = ANY(${param_idx})")
query_params.append(nums)
param_idx += 1
if active:
# Active now: started and not yet ended.
conditions.append("time <= now() AND (expires IS NULL OR expires > now())")
if since:
conditions.append(f"time >= ${param_idx}")
query_params.append(since)
@ -2979,38 +3170,34 @@ async def events_list(request: Request) -> HTMLResponse:
params = request.query_params
# Parse parameters
parsed, error = _parse_events_params(params)
# Parse parameters (GUI defaults to Last 24h when no time filter is given).
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
# Get system settings for map tiles
# Get system settings for map tiles + DISTINCT filter-option lists.
pool = get_pool()
all_categories: list[str] = []
all_event_types: list[str] = []
async with pool.acquire() as conn:
system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system")
try:
cat_rows = await conn.fetch("SELECT DISTINCT category FROM events ORDER BY 1")
all_categories = [r["category"] for r in cat_rows]
et_rows = await conn.fetch(
"SELECT DISTINCT split_part(category, '.', 1) AS et FROM events ORDER BY 1"
)
all_event_types = [r["et"] for r in et_rows]
except Exception:
logger.warning("Failed to load filter options", exc_info=True)
tile_url = system_row["map_tile_url"] if system_row else "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
tile_attribution = system_row["map_attribution"] if system_row else "OpenStreetMap"
# Prepare filter values for template
filter_values = {
"adapter": params.get("adapter", ""),
"category": params.get("category", ""),
"since": params.get("since", ""),
"until": params.get("until", ""),
"region_north": params.get("region_north", ""),
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"limit": params.get("limit", "50"),
}
events = []
next_cursor = None
if error:
# Validation error - show error banner but don't fail the page
pass
else:
# Fetch events
result = await _fetch_events(parsed)
if result.error:
error = result.error
@ -3018,15 +3205,25 @@ async def events_list(request: Request) -> HTMLResponse:
events = result.events
next_cursor = result.next_cursor
# Add table-only display fields (time_human, adapter_display, geometry_summary)
_decorate_table_events(events)
# Registry-derived adapter list for the filter <select> and map legend.
# Sorted by name for stable ordering; index drives the legend color palette.
adapters = [
{"name": cls.name, "display_name": cls.display_name}
for cls in sorted(discover_adapters().values(), key=lambda c: c.name)
]
adapters_flat, adapters_grouped = _adapter_filter_options()
pstate = parsed or {}
filter_state = {
"q": pstate.get("q") or "",
"adapters": pstate.get("adapters") or [],
"categories": pstate.get("categories") or [],
"event_types": pstate.get("event_types") or [],
"severities": pstate.get("severities") or [],
"time_token": pstate.get("time_token") or DEFAULT_TIME,
"region_north": params.get("region_north", ""),
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"limit": str(pstate.get("limit", 50)),
}
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []
query_string = urlencode([(k, v) for k, v in _query_items(params) if k != "cursor"])
return templates.TemplateResponse(
request=request,
@ -3036,11 +3233,19 @@ async def events_list(request: Request) -> HTMLResponse:
"csrf_token": csrf_token,
"events": events,
"next_cursor": next_cursor,
"filter_values": filter_values,
"filter_error": error,
"tile_url": tile_url,
"tile_attribution": tile_attribution,
"adapters": adapters,
"adapters": adapters_flat,
"adapters_grouped": adapters_grouped,
"all_categories": all_categories,
"all_event_types": all_event_types,
"severity_order": SEVERITY_ORDER,
"time_presets": [(t, TIME_PRESET_LABELS[t]) for t in
("last_15m", "last_1h", "last_6h", "last_24h", "last_7d", "active", "all")],
"filter_state": filter_state,
"active_pills": active_pills,
"query_string": query_string,
},
)
@ -3052,25 +3257,11 @@ async def events_rows(request: Request) -> HTMLResponse:
params = request.query_params
# Parse parameters
parsed, error = _parse_events_params(params)
# Prepare filter values for template
filter_values = {
"adapter": params.get("adapter", ""),
"category": params.get("category", ""),
"since": params.get("since", ""),
"until": params.get("until", ""),
"region_north": params.get("region_north", ""),
"region_south": params.get("region_south", ""),
"region_east": params.get("region_east", ""),
"region_west": params.get("region_west", ""),
"limit": params.get("limit", "50"),
}
# Parse parameters (same GUI default as the page).
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
events = []
next_cursor = None
if error:
pass
else:
@ -3081,16 +3272,25 @@ async def events_rows(request: Request) -> HTMLResponse:
events = result.events
next_cursor = result.next_cursor
# Add table-only display fields (time_human, adapter_display, geometry_summary)
_decorate_table_events(events)
return templates.TemplateResponse(
adapters_flat, _ = _adapter_filter_options()
active_pills = _build_active_pills(parsed or {}, len(adapters_flat)) if parsed else []
query_string = urlencode([(k, v) for k, v in _query_items(params) if k != "cursor"])
response = templates.TemplateResponse(
request=request,
name="_events_rows.html",
context={
"events": events,
"next_cursor": next_cursor,
"filter_values": filter_values,
"filter_error": error,
"active_pills": active_pills,
"query_string": query_string,
"oob_pills": True, # emit an out-of-band #active-pills update on swap
},
)
# Push the bookmarkable full-page URL (not the /events/rows fragment path),
# so back/forward + bookmarking land on the same filtered view.
response.headers["HX-Push-Url"] = "/events?" + str(request.url.query)
return response

View file

@ -0,0 +1,16 @@
{# Active-filter pills (v0.7.1). Server-rendered from the parsed filter state so
the operator always sees what's applied. Each × clears that filter (JS) and
re-submits; this partial is re-rendered out-of-band into #active-pills. #}
{% if active_pills %}
<div class="active-pills">
<span class="active-pills-label">Active filters:</span>
{% for pill in active_pills %}
<span class="filter-pill" data-pill-key="{{ pill.key }}">
{{ pill.label }}
<button type="button" class="pill-remove" data-remove-filter="{{ pill.key }}"
aria-label="Remove {{ pill.label }}">&times;</button>
</span>
{% endfor %}
<button type="button" class="pill-clear-all" data-clear-all>Clear all</button>
</div>
{% endif %}

View file

@ -0,0 +1,47 @@
{# Multi-select chip-picker (v0.7.1). Shared by the adapter / category /
event-type / severity filters. Selection is held in a hidden CSV input
(name=field); the checkboxes are synced into it by JS and the form
auto-applies when the panel closes. `options` is either a flat list of
strings/dicts {value,label,color} or, when grouped=True, a list of
(group_label, [dict,...]) tuples. `selected` is the list of chosen values. #}
{% macro _chip_row(field, opt, selected, with_swatch) %}
{% set val = opt.value if opt.value is defined else opt %}
{% set lbl = opt.label if opt.label is defined else opt %}
<label class="chip-row" data-chip-label="{{ lbl | lower }}">
<input type="checkbox" class="chip-cb" data-field="{{ field }}" value="{{ val }}"
{{ 'checked' if val in selected else '' }}>
{% if with_swatch and opt.color is defined %}
<span class="chip-swatch" style="background:{{ opt.color }}"></span>
{% endif %}
<span>{{ lbl }}</span>
</label>
{% endmacro %}
{% macro chip_picker(field, label, options, selected, grouped=False, searchable=False, with_swatch=False) %}
<div class="chip-picker" data-field="{{ field }}">
<button type="button" class="chip-picker-toggle outline" data-toggle="{{ field }}"
aria-haspopup="true" aria-expanded="false">
{{ label }}<span class="chip-count" data-count-for="{{ field }}">{{ (' (' ~ selected | length ~ ')') if selected else '' }}</span>
</button>
<input type="hidden" name="{{ field }}" id="filter-{{ field }}" value="{{ selected | join(',') }}">
<div class="chip-picker-panel" data-panel="{{ field }}" hidden>
{% if searchable %}
<input type="text" class="chip-picker-search" data-search-for="{{ field }}"
placeholder="Filter {{ label | lower }}…" autocomplete="off">
{% endif %}
<div class="chip-picker-list">
{% if grouped %}
{% for group_label, items in options %}
<div class="chip-group">
<div class="chip-group-header">{{ group_label }}</div>
{% for opt in items %}{{ _chip_row(field, opt, selected, with_swatch) }}{% endfor %}
</div>
{% endfor %}
{% else %}
{% for opt in options %}{{ _chip_row(field, opt, selected, with_swatch) }}{% endfor %}
{% endif %}
</div>
</div>
</div>
{% endmacro %}

View file

@ -70,9 +70,10 @@
<div class="pagination-info">
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
{% if next_cursor %}
<a href="/events?cursor={{ next_cursor }}{% if filter_values.adapter %}&amp;adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&amp;category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&amp;since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&amp;until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&amp;region_north={{ filter_values.region_north }}&amp;region_south={{ filter_values.region_south }}&amp;region_east={{ filter_values.region_east }}&amp;region_west={{ filter_values.region_west }}{% endif %}&amp;limit={{ filter_values.limit }}"
{% set next_qs = "cursor=" ~ next_cursor ~ ("&" ~ query_string if query_string else "") %}
<a href="/events?{{ next_qs }}"
role="button"
hx-get="/events/rows?cursor={{ next_cursor }}{% if filter_values.adapter %}&amp;adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&amp;category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&amp;since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&amp;until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&amp;region_north={{ filter_values.region_north }}&amp;region_south={{ filter_values.region_south }}&amp;region_east={{ filter_values.region_east }}&amp;region_west={{ filter_values.region_west }}{% endif %}&amp;limit={{ filter_values.limit }}"
hx-get="/events/rows?{{ next_qs }}"
hx-target="#events-rows"
hx-push-url="true">
Next &rarr;
@ -86,3 +87,7 @@
<p>No events match the filters.</p>
</article>
{% endif %}
{% if oob_pills %}
{# Out-of-band update of the page-level active-pills bar on each HTMX swap. #}
<div id="active-pills" hx-swap-oob="true">{% include "_active_pills.html" %}</div>
{% endif %}

View file

@ -102,6 +102,42 @@
justify-content: space-between;
align-items: center;
}
/* --- v0.7.1 filter row (functional layout; full polish in a later pass) --- */
.filter-search { width: 100%; margin-bottom: 0.5rem; }
.filter-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-start; }
.filter-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; justify-content: flex-end; }
.filter-apply { width: 120px; }
.chip-picker { position: relative; }
.chip-picker-toggle { white-space: nowrap; }
.chip-picker-panel {
position: absolute; z-index: 1000; top: 100%; left: 0; margin-top: 0.25rem;
min-width: 16rem; max-height: 22rem; overflow-y: auto;
background: var(--pico-card-background-color);
border: 1px solid var(--pico-muted-border-color);
border-radius: 0.375rem; padding: 0.5rem; box-shadow: 0 4px 14px rgba(0,0,0,0.18);
}
.chip-picker-search { width: 100%; margin-bottom: 0.5rem; }
.chip-group-header { font-size: 0.75rem; font-weight: 600; color: var(--pico-muted-color);
text-transform: uppercase; margin: 0.4rem 0 0.2rem; }
.chip-row { display: flex; align-items: center; gap: 0.5rem; padding: 0.15rem 0;
cursor: pointer; font-weight: 400; }
.chip-row input[type="checkbox"] { width: auto; margin: 0; }
.chip-swatch { width: 0.85rem; height: 0.85rem; border-radius: 2px; flex: 0 0 auto; }
.chip-row.chip-hidden { display: none; }
.time-preset { display: block; width: 100%; text-align: left; background: none;
border: none; padding: 0.35rem 0.4rem; color: var(--pico-color); cursor: pointer; }
.time-preset:hover, .time-preset.selected { background: var(--pico-primary-focus); }
.time-custom { border-top: 1px solid var(--pico-muted-border-color); margin-top: 0.4rem;
padding-top: 0.4rem; display: flex; flex-direction: column; gap: 0.3rem; }
#active-pills .active-pills { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center;
margin: 0.5rem 0; }
.active-pills-label { font-size: 0.8rem; color: var(--pico-muted-color); }
.filter-pill { display: inline-flex; align-items: center; gap: 0.35rem;
background: var(--pico-primary-focus); border-radius: 999px; padding: 0.15rem 0.6rem;
font-size: 0.85rem; }
.pill-remove, .pill-clear-all { background: none; border: none; cursor: pointer;
color: var(--pico-color); padding: 0; font-size: 1rem; line-height: 1; width: auto; }
.pill-clear-all { font-size: 0.8rem; text-decoration: underline; }
</style>
{% endblock %}
@ -120,52 +156,58 @@
</article>
{% endif %}
<details open>
<summary>Filters</summary>
<form id="filter-form" class="filter-form" action="/events" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
{% from "_chip_picker.html" import chip_picker %}
<form id="filter-form" class="filter-form" action="/events" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
<div class="grid">
<div>
<label for="adapter">Adapter</label>
<select id="adapter" name="adapter">
<option value="">All</option>
{% for a in adapters %}
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="category">Category</label>
<input type="text" id="category" name="category" placeholder="Exact match"
value="{{ filter_values.category }}">
</div>
<div>
<label for="since">From</label>
<input type="datetime-local" id="since" name="since"
value="{{ filter_values.since }}">
</div>
<div>
<label for="until">To</label>
<input type="datetime-local" id="until" name="until"
value="{{ filter_values.until }}">
{# Full-width search (server-side ILIKE over subject + location). #}
<input type="search" id="filter-q" name="q" class="filter-search"
placeholder="Search subject or location…" autocomplete="off"
value="{{ filter_state.q }}">
{# Chip-picker row: adapter (grouped, swatches), category, event type,
severity, plus the time-preset dropdown. #}
<div class="filter-row">
{{ chip_picker("adapter", "Adapters", adapters_grouped, filter_state.adapters,
grouped=True, searchable=True, with_swatch=True) }}
{{ chip_picker("category", "Categories", all_categories, filter_state.categories,
searchable=True) }}
{{ chip_picker("event_type", "Event Types", all_event_types, filter_state.event_types) }}
{{ chip_picker("severity", "Severity", severity_order, filter_state.severities) }}
{# Time preset dropdown (bespoke; not a chip-picker). #}
<div class="chip-picker time-picker" data-field="time">
<button type="button" class="chip-picker-toggle outline" aria-expanded="false"
data-toggle="time" id="time-toggle">Time</button>
<input type="hidden" name="time" id="filter-time" value="{{ filter_state.time_token }}">
<div class="chip-picker-panel" data-panel="time" hidden>
{% for token, label in time_presets %}
<button type="button" class="time-preset" data-time-value="{{ token }}">{{ label }}</button>
{% endfor %}
<div class="time-custom">
<label>From <input type="datetime-local" id="time-from"></label>
<label>To <input type="datetime-local" id="time-to"></label>
<button type="button" class="time-custom-apply" data-time-custom>Apply range</button>
</div>
</div>
</div>
</div>
<!-- Hidden region inputs (managed by map viewport) -->
<input type="hidden" id="region_north" name="region_north" value="{{ filter_values.region_north }}">
<input type="hidden" id="region_south" name="region_south" value="{{ filter_values.region_south }}">
<input type="hidden" id="region_east" name="region_east" value="{{ filter_values.region_east }}">
<input type="hidden" id="region_west" name="region_west" value="{{ filter_values.region_west }}">
{# Hidden region inputs (managed by map viewport) + limit. #}
<input type="hidden" id="region_north" name="region_north" value="{{ filter_state.region_north }}">
<input type="hidden" id="region_south" name="region_south" value="{{ filter_state.region_south }}">
<input type="hidden" id="region_east" name="region_east" value="{{ filter_state.region_east }}">
<input type="hidden" id="region_west" name="region_west" value="{{ filter_state.region_west }}">
<input type="hidden" name="limit" value="{{ filter_state.limit }}">
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
<div class="filter-actions">
<button type="submit" class="filter-apply">Apply</button>
<a href="/events" role="button" class="outline" id="filter-clear-all">Clear all</a>
</div>
</form>
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
<button type="submit">Apply</button>
<a href="/events" role="button" class="outline">Clear Filters</a>
</div>
</form>
</details>
{# Active filter pills — server-rendered; updated out-of-band on each swap. #}
<div id="active-pills">{% include "_active_pills.html" %}</div>
<div id="events-map"></div>
<div class="map-controls">
@ -566,4 +608,124 @@
})();
</script>
<script>
// --- v0.7.1 filter behavior (vanilla JS + HTMX; no framework) --------------
(function () {
var form = document.getElementById("filter-form");
if (!form) return;
function submitForm() { if (window.htmx) htmx.trigger(form, "submit"); }
// Sync a chip-picker's checked checkboxes into its hidden CSV input + badge.
function syncField(field) {
var boxes = form.querySelectorAll('.chip-cb[data-field="' + field + '"]');
var vals = [];
boxes.forEach(function (b) { if (b.checked) vals.push(b.value); });
var hidden = document.getElementById("filter-" + field);
if (hidden) hidden.value = vals.join(",");
var badge = form.querySelector('[data-count-for="' + field + '"]');
if (badge) badge.textContent = vals.length ? " (" + vals.length + ")" : "";
}
function closeAllPanels(except) {
form.querySelectorAll(".chip-picker-panel").forEach(function (p) {
if (p !== except && !p.hidden) {
p.hidden = true;
var t = form.querySelector('[data-toggle="' + p.dataset.panel + '"]');
if (t) t.setAttribute("aria-expanded", "false");
// Auto-apply on close for the multi-select chip-pickers.
if (p.dataset.panel && p.dataset.panel !== "time") submitForm();
}
});
}
// Toggle panels open/closed.
form.querySelectorAll(".chip-picker-toggle").forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault();
var field = btn.dataset.toggle;
var panel = form.querySelector('.chip-picker-panel[data-panel="' + field + '"]');
if (!panel) return;
var willOpen = panel.hidden;
closeAllPanels(willOpen ? panel : null);
panel.hidden = !willOpen;
btn.setAttribute("aria-expanded", willOpen ? "true" : "false");
});
});
// Checkbox change -> keep hidden CSV in sync (apply happens on close).
form.querySelectorAll(".chip-cb").forEach(function (cb) {
cb.addEventListener("change", function () { syncField(cb.dataset.field); });
});
// In-panel search: filter visible chip rows by label substring.
form.querySelectorAll(".chip-picker-search").forEach(function (inp) {
inp.addEventListener("input", function () {
var field = inp.dataset.searchFor;
var term = inp.value.trim().toLowerCase();
var panel = form.querySelector('.chip-picker-panel[data-panel="' + field + '"]');
panel.querySelectorAll(".chip-row").forEach(function (row) {
var lbl = row.dataset.chipLabel || "";
row.classList.toggle("chip-hidden", term && lbl.indexOf(term) === -1);
});
});
});
// Time presets.
var timeHidden = document.getElementById("filter-time");
form.querySelectorAll(".time-preset").forEach(function (btn) {
btn.addEventListener("click", function () {
timeHidden.value = btn.dataset.timeValue;
closeAllPanels(null);
submitForm();
});
});
var customBtn = form.querySelector("[data-time-custom]");
if (customBtn) {
customBtn.addEventListener("click", function () {
var f = document.getElementById("time-from").value;
var t = document.getElementById("time-to").value;
if (!f && !t) return;
timeHidden.value = "custom:" + (f || "") + "," + (t || "");
closeAllPanels(null);
submitForm();
});
}
// Debounced search box (300ms) -> auto-apply.
var qInput = document.getElementById("filter-q");
if (qInput) {
var qTimer = null;
qInput.addEventListener("input", function () {
if (qTimer) clearTimeout(qTimer);
qTimer = setTimeout(submitForm, 300);
});
}
// Close panels when clicking outside.
document.addEventListener("click", function (e) {
if (!e.target.closest(".chip-picker")) closeAllPanels(null);
});
// Active-pill removal + clear-all (delegated; #active-pills is OOB-swapped).
document.addEventListener("click", function (e) {
var rm = e.target.closest("[data-remove-filter]");
if (rm) {
var key = rm.dataset.removeFilter;
if (key === "q") { if (qInput) qInput.value = ""; }
else if (key === "time") { timeHidden.value = "all"; }
else {
form.querySelectorAll('.chip-cb[data-field="' + key + '"]').forEach(function (b) {
b.checked = false;
});
syncField(key);
}
submitForm();
return;
}
if (e.target.closest("[data-clear-all]")) { window.location.href = "/events"; }
});
})();
</script>
{% endblock %}

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:

View file

@ -0,0 +1,210 @@
"""Tests for the v0.7.1 /events filtering (search, multi-select, time presets,
active pills, URL round-trip). Covers the pure helpers and the query builder
(via a captured-SQL mock), with no live DB.
"""
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.gui import routes
NOW = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
# --- pure helpers -----------------------------------------------------------
def test_csv_param_splits_and_trims():
assert routes._csv_param({"adapter": "nws, firms ,usgs_quake"}, "adapter") == \
["nws", "firms", "usgs_quake"]
assert routes._csv_param({}, "adapter") == []
assert routes._csv_param({"adapter": ""}, "adapter") == []
def test_ilike_escape_escapes_wildcards():
assert routes._ilike_escape("50%_off\\x") == "50\\%\\_off\\\\x"
@pytest.mark.parametrize("token,delta", [
("last_15m", timedelta(minutes=15)),
("last_1h", timedelta(hours=1)),
("last_6h", timedelta(hours=6)),
("last_24h", timedelta(hours=24)),
("last_7d", timedelta(days=7)),
])
def test_resolve_time_presets(token, delta):
since, until, active, err = routes._resolve_time(token, NOW)
assert err is None and active is False and until is None
assert since == NOW - delta
def test_resolve_time_active_all_custom():
assert routes._resolve_time("active", NOW) == (None, None, True, None)
assert routes._resolve_time("all", NOW) == (None, None, False, None)
assert routes._resolve_time(None, NOW) == (None, None, False, None)
since, until, active, err = routes._resolve_time("custom:2026-05-01T00:00,2026-05-02T00:00", NOW)
assert err is None and active is False
# datetime-local values carry no tz (naive), matching the legacy since/until path.
assert since == datetime(2026, 5, 1)
assert until == datetime(2026, 5, 2)
def test_resolve_time_custom_inverted_is_error():
_, _, _, err = routes._resolve_time("custom:2026-05-02T00:00,2026-05-01T00:00", NOW)
assert err and "before" in err
def test_resolve_time_unknown_token_is_error():
_, _, _, err = routes._resolve_time("yesterday", NOW)
assert err and "Unknown" in err
# --- _parse_events_params ---------------------------------------------------
def test_parse_multi_value_csv():
parsed, err = routes._parse_events_params({
"adapter": "nws,firms", "category": "quake.event.light,wx.alert.tornado_warning",
"event_type": "fire,quake", "severity": "critical,high", "q": "wildfire", "time": "all",
})
assert err is None
assert parsed["adapters"] == ["nws", "firms"]
assert parsed["categories"] == ["quake.event.light", "wx.alert.tornado_warning"]
assert parsed["event_types"] == ["fire", "quake"]
assert parsed["severities"] == ["critical", "high"]
assert parsed["q"] == "wildfire"
def test_parse_drops_unknown_severity_labels():
parsed, err = routes._parse_events_params({"severity": "critical,bogus,low"})
assert err is None
assert parsed["severities"] == ["critical", "low"]
def test_parse_gui_defaults_to_last_24h():
parsed, _ = routes._parse_events_params({}, default_time="last_24h")
assert parsed["time_token"] == "last_24h"
assert parsed["since"] is not None
def test_parse_json_api_has_no_default_time():
parsed, _ = routes._parse_events_params({}) # no default_time (events.json)
assert parsed["time_token"] is None
assert parsed["since"] is None and parsed["until"] is None
def test_parse_legacy_since_until_still_honored():
parsed, err = routes._parse_events_params(
{"since": "2026-05-17T00:00:00", "until": "2026-05-17T12:00:00"},
default_time="last_24h",
)
assert err is None
assert parsed["since"] == datetime(2026, 5, 17, 0, 0)
assert parsed["time_token"] is None # legacy path, not a preset
# --- query builder (captured SQL) -------------------------------------------
async def _capture(parsed):
captured = {}
async def fake_fetch(query, *args):
captured["query"] = query
captured["params"] = list(args)
return []
conn = MagicMock()
conn.fetch = fake_fetch
pool = MagicMock()
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
with patch("central.gui.routes.get_pool", return_value=pool):
await routes._fetch_events(parsed)
return captured
@pytest.mark.asyncio
async def test_multi_adapter_uses_any():
parsed, _ = routes._parse_events_params({"adapter": "nws,firms,usgs_quake", "time": "all"})
cap = await _capture(parsed)
assert "adapter = ANY($" in cap["query"]
assert ["nws", "firms", "usgs_quake"] in cap["params"]
@pytest.mark.asyncio
async def test_event_type_uses_split_part():
parsed, _ = routes._parse_events_params({"event_type": "fire,quake", "time": "all"})
cap = await _capture(parsed)
assert "split_part(category, '.', 1) = ANY($" in cap["query"]
assert ["fire", "quake"] in cap["params"]
@pytest.mark.asyncio
async def test_severity_unknown_includes_null():
parsed, _ = routes._parse_events_params({"severity": "critical,unknown", "time": "all"})
cap = await _capture(parsed)
assert "severity = ANY($" in cap["query"] and "OR severity IS NULL" in cap["query"]
assert [0, 4] in cap["params"] # unknown->0, critical->4 (sorted)
@pytest.mark.asyncio
async def test_severity_without_unknown_has_no_null_clause():
parsed, _ = routes._parse_events_params({"severity": "high", "time": "all"})
cap = await _capture(parsed)
assert "OR severity IS NULL" not in cap["query"]
assert [3] in cap["params"]
@pytest.mark.asyncio
async def test_active_now_condition():
parsed, _ = routes._parse_events_params({"time": "active"})
cap = await _capture(parsed)
assert "time <= now() AND (expires IS NULL OR expires > now())" in cap["query"]
@pytest.mark.asyncio
async def test_search_is_parameterized_and_escaped():
# A term loaded with SQL/LIKE metacharacters must be safely parameterized.
parsed, _ = routes._parse_events_params({"q": "50%_x'; DROP TABLE events;--", "time": "all"})
cap = await _capture(parsed)
assert "(payload->'data'->'data')::text ILIKE $" in cap["query"]
assert "ESCAPE '\\'" in cap["query"]
# The dangerous term never appears inline in the SQL; only as a bound param.
assert "DROP TABLE" not in cap["query"]
assert cap["params"][0] == "%50\\%\\_x'; DROP TABLE events;--%"
# --- active pills + URL round-trip ------------------------------------------
def test_build_active_pills():
parsed = {
"q": "fire", "adapters": ["nws", "firms"], "categories": ["a", "b"],
"event_types": ["fire"], "severities": ["high", "critical"], "time_token": "last_24h",
}
pills = {p["key"]: p["label"] for p in routes._build_active_pills(parsed, adapter_total=12)}
assert pills["q"] == 'Search: "fire"'
assert pills["adapter"] == "Adapters: 2 of 12"
assert pills["category"] == "Categories: 2"
assert pills["event_type"] == "Event types: fire"
assert pills["severity"] == "Severity: critical, high" # ranked order
assert pills["time"] == "Last 24 hours"
def test_no_time_pill_for_all():
pills = routes._build_active_pills({"time_token": "all"}, adapter_total=12)
assert all(p["key"] != "time" for p in pills)
def test_url_round_trip():
q = {"q": "fire", "adapter": "nws,firms", "category": "quake.event.light",
"event_type": "fire", "severity": "critical,high", "time": "last_1h", "limit": "50"}
parsed, err = routes._parse_events_params(q)
assert err is None
# Re-serialize the parsed multi-values as CSV and re-parse: identical state.
requery = {
"q": parsed["q"], "adapter": ",".join(parsed["adapters"]),
"category": ",".join(parsed["categories"]), "event_type": ",".join(parsed["event_types"]),
"severity": ",".join(parsed["severities"]), "time": parsed["time_token"], "limit": "50",
}
reparsed, _ = routes._parse_events_params(requery)
for k in ("q", "adapters", "categories", "event_types", "severities", "time_token"):
assert reparsed[k] == parsed[k]