mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 11:54:37 +02:00
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:
parent
6af419c345
commit
380cde31f8
7 changed files with 785 additions and 141 deletions
|
|
@ -5,8 +5,9 @@ import html
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
logger = logging.getLogger("central.gui.routes")
|
logger = logging.getLogger("central.gui.routes")
|
||||||
|
|
||||||
|
|
@ -2616,10 +2617,159 @@ class EventsQueryResult:
|
||||||
self.error = error
|
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.
|
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:
|
Returns:
|
||||||
(parsed_params, error_message)
|
(parsed_params, error_message)
|
||||||
If error_message is not None, parsed_params is None.
|
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:
|
if limit < 1 or limit > 200:
|
||||||
return None, "limit must be between 1 and 200"
|
return None, "limit must be between 1 and 200"
|
||||||
|
|
||||||
# Parse adapter filter
|
# Multi-value filters (comma-separated; a single value is just a CSV of
|
||||||
adapter = params.get("adapter")
|
# length 1, preserving the old single-value API). Unknown severity labels
|
||||||
if adapter == "":
|
# are ignored rather than erroring.
|
||||||
adapter = None
|
q = (params.get("q") or "").strip()[:200] or None
|
||||||
|
adapters = _csv_param(params, "adapter")
|
||||||
# Parse category filter
|
categories = _csv_param(params, "category")
|
||||||
category = params.get("category")
|
event_types = _csv_param(params, "event_type")
|
||||||
if category == "":
|
severities = [s for s in _csv_param(params, "severity") if s in SEVERITY_LABELS]
|
||||||
category = None
|
|
||||||
|
|
||||||
# Parse since/until filters
|
|
||||||
since = None
|
|
||||||
until = None
|
|
||||||
|
|
||||||
|
# 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")
|
since_str = params.get("since")
|
||||||
if since_str:
|
if since_str:
|
||||||
try:
|
try:
|
||||||
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
|
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, f"Invalid ISO 8601 datetime for since: {since_str}"
|
return None, f"Invalid ISO 8601 datetime for since: {since_str}"
|
||||||
|
|
||||||
until_str = params.get("until")
|
until_str = params.get("until")
|
||||||
if until_str:
|
if until_str:
|
||||||
try:
|
try:
|
||||||
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
|
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, f"Invalid ISO 8601 datetime for until: {until_str}"
|
return None, f"Invalid ISO 8601 datetime for until: {until_str}"
|
||||||
|
|
||||||
# Validate since <= until
|
|
||||||
if since and until and since > until:
|
if since and until and since > until:
|
||||||
return None, "since must be before or equal to 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
|
# Parse region bbox
|
||||||
region_north = params.get("region_north")
|
region_north = params.get("region_north")
|
||||||
|
|
@ -2729,10 +2884,15 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"adapter": adapter,
|
"q": q,
|
||||||
"category": category,
|
"adapters": adapters,
|
||||||
|
"categories": categories,
|
||||||
|
"event_types": event_types,
|
||||||
|
"severities": severities,
|
||||||
|
"time_token": time_token,
|
||||||
"since": since,
|
"since": since,
|
||||||
"until": until,
|
"until": until,
|
||||||
|
"active": active,
|
||||||
"bbox": bbox,
|
"bbox": bbox,
|
||||||
"cursor_time": cursor_time,
|
"cursor_time": cursor_time,
|
||||||
"cursor_id": cursor_id,
|
"cursor_id": cursor_id,
|
||||||
|
|
@ -2766,10 +2926,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
|
|
||||||
limit = parsed_params["limit"]
|
limit = parsed_params["limit"]
|
||||||
adapter = parsed_params["adapter"]
|
q = parsed_params.get("q")
|
||||||
category = parsed_params["category"]
|
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"]
|
since = parsed_params["since"]
|
||||||
until = parsed_params["until"]
|
until = parsed_params["until"]
|
||||||
|
active = parsed_params.get("active", False)
|
||||||
bbox = parsed_params["bbox"]
|
bbox = parsed_params["bbox"]
|
||||||
cursor_time = parsed_params["cursor_time"]
|
cursor_time = parsed_params["cursor_time"]
|
||||||
cursor_id = parsed_params["cursor_id"]
|
cursor_id = parsed_params["cursor_id"]
|
||||||
|
|
@ -2779,16 +2943,43 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
query_params = []
|
query_params = []
|
||||||
param_idx = 1
|
param_idx = 1
|
||||||
|
|
||||||
if adapter:
|
if q:
|
||||||
conditions.append(f"adapter = ${param_idx}")
|
# Subject and location are both derived from the inner adapter payload
|
||||||
query_params.append(adapter)
|
# (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
|
param_idx += 1
|
||||||
|
|
||||||
if category:
|
if adapters:
|
||||||
conditions.append(f"category = ${param_idx}")
|
conditions.append(f"adapter = ANY(${param_idx})")
|
||||||
query_params.append(category)
|
query_params.append(adapters)
|
||||||
param_idx += 1
|
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:
|
if since:
|
||||||
conditions.append(f"time >= ${param_idx}")
|
conditions.append(f"time >= ${param_idx}")
|
||||||
query_params.append(since)
|
query_params.append(since)
|
||||||
|
|
@ -2979,38 +3170,34 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
|
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
|
|
||||||
# Parse parameters
|
# Parse parameters (GUI defaults to Last 24h when no time filter is given).
|
||||||
parsed, error = _parse_events_params(params)
|
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()
|
pool = get_pool()
|
||||||
|
all_categories: list[str] = []
|
||||||
|
all_event_types: list[str] = []
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system")
|
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_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"
|
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 = []
|
events = []
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
# Validation error - show error banner but don't fail the page
|
# Validation error - show error banner but don't fail the page
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
# Fetch events
|
|
||||||
result = await _fetch_events(parsed)
|
result = await _fetch_events(parsed)
|
||||||
if result.error:
|
if result.error:
|
||||||
error = result.error
|
error = result.error
|
||||||
|
|
@ -3018,15 +3205,25 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
events = result.events
|
events = result.events
|
||||||
next_cursor = result.next_cursor
|
next_cursor = result.next_cursor
|
||||||
|
|
||||||
# Add table-only display fields (time_human, adapter_display, geometry_summary)
|
|
||||||
_decorate_table_events(events)
|
_decorate_table_events(events)
|
||||||
|
|
||||||
# Registry-derived adapter list for the filter <select> and map legend.
|
adapters_flat, adapters_grouped = _adapter_filter_options()
|
||||||
# Sorted by name for stable ordering; index drives the legend color palette.
|
pstate = parsed or {}
|
||||||
adapters = [
|
filter_state = {
|
||||||
{"name": cls.name, "display_name": cls.display_name}
|
"q": pstate.get("q") or "",
|
||||||
for cls in sorted(discover_adapters().values(), key=lambda c: c.name)
|
"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(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
|
|
@ -3036,11 +3233,19 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
"csrf_token": csrf_token,
|
"csrf_token": csrf_token,
|
||||||
"events": events,
|
"events": events,
|
||||||
"next_cursor": next_cursor,
|
"next_cursor": next_cursor,
|
||||||
"filter_values": filter_values,
|
|
||||||
"filter_error": error,
|
"filter_error": error,
|
||||||
"tile_url": tile_url,
|
"tile_url": tile_url,
|
||||||
"tile_attribution": tile_attribution,
|
"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
|
params = request.query_params
|
||||||
|
|
||||||
# Parse parameters
|
# Parse parameters (same GUI default as the page).
|
||||||
parsed, error = _parse_events_params(params)
|
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
|
||||||
|
|
||||||
# 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 = []
|
events = []
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
|
@ -3081,16 +3272,25 @@ async def events_rows(request: Request) -> HTMLResponse:
|
||||||
events = result.events
|
events = result.events
|
||||||
next_cursor = result.next_cursor
|
next_cursor = result.next_cursor
|
||||||
|
|
||||||
# Add table-only display fields (time_human, adapter_display, geometry_summary)
|
|
||||||
_decorate_table_events(events)
|
_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,
|
request=request,
|
||||||
name="_events_rows.html",
|
name="_events_rows.html",
|
||||||
context={
|
context={
|
||||||
"events": events,
|
"events": events,
|
||||||
"next_cursor": next_cursor,
|
"next_cursor": next_cursor,
|
||||||
"filter_values": filter_values,
|
|
||||||
"filter_error": error,
|
"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
|
||||||
|
|
|
||||||
16
src/central/gui/templates/_active_pills.html
Normal file
16
src/central/gui/templates/_active_pills.html
Normal 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 }}">×</button>
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
<button type="button" class="pill-clear-all" data-clear-all>Clear all</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
47
src/central/gui/templates/_chip_picker.html
Normal file
47
src/central/gui/templates/_chip_picker.html
Normal 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 %}
|
||||||
|
|
@ -70,9 +70,10 @@
|
||||||
<div class="pagination-info">
|
<div class="pagination-info">
|
||||||
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
|
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
|
||||||
{% if next_cursor %}
|
{% if next_cursor %}
|
||||||
<a href="/events?cursor={{ next_cursor }}{% if filter_values.adapter %}&adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&region_north={{ filter_values.region_north }}&region_south={{ filter_values.region_south }}&region_east={{ filter_values.region_east }}&region_west={{ filter_values.region_west }}{% endif %}&limit={{ filter_values.limit }}"
|
{% set next_qs = "cursor=" ~ next_cursor ~ ("&" ~ query_string if query_string else "") %}
|
||||||
|
<a href="/events?{{ next_qs }}"
|
||||||
role="button"
|
role="button"
|
||||||
hx-get="/events/rows?cursor={{ next_cursor }}{% if filter_values.adapter %}&adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&region_north={{ filter_values.region_north }}&region_south={{ filter_values.region_south }}&region_east={{ filter_values.region_east }}&region_west={{ filter_values.region_west }}{% endif %}&limit={{ filter_values.limit }}"
|
hx-get="/events/rows?{{ next_qs }}"
|
||||||
hx-target="#events-rows"
|
hx-target="#events-rows"
|
||||||
hx-push-url="true">
|
hx-push-url="true">
|
||||||
Next →
|
Next →
|
||||||
|
|
@ -86,3 +87,7 @@
|
||||||
<p>No events match the filters.</p>
|
<p>No events match the filters.</p>
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% 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 %}
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,42 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
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>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -120,52 +156,58 @@
|
||||||
</article>
|
</article>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<details open>
|
{% from "_chip_picker.html" import chip_picker %}
|
||||||
<summary>Filters</summary>
|
|
||||||
<form id="filter-form" class="filter-form" action="/events" method="get"
|
<form id="filter-form" class="filter-form" action="/events" method="get"
|
||||||
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
|
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
|
||||||
|
|
||||||
<div class="grid">
|
{# Full-width search (server-side ILIKE over subject + location). #}
|
||||||
<div>
|
<input type="search" id="filter-q" name="q" class="filter-search"
|
||||||
<label for="adapter">Adapter</label>
|
placeholder="Search subject or location…" autocomplete="off"
|
||||||
<select id="adapter" name="adapter">
|
value="{{ filter_state.q }}">
|
||||||
<option value="">All</option>
|
|
||||||
{% for a in adapters %}
|
{# Chip-picker row: adapter (grouped, swatches), category, event type,
|
||||||
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
|
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 %}
|
{% endfor %}
|
||||||
</select>
|
<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>
|
|
||||||
<label for="category">Category</label>
|
|
||||||
<input type="text" id="category" name="category" placeholder="Exact match"
|
|
||||||
value="{{ filter_values.category }}">
|
|
||||||
</div>
|
</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 }}">
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Hidden region inputs (managed by map viewport) -->
|
{# Hidden region inputs (managed by map viewport) + limit. #}
|
||||||
<input type="hidden" id="region_north" name="region_north" value="{{ filter_values.region_north }}">
|
<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_values.region_south }}">
|
<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_values.region_east }}">
|
<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_values.region_west }}">
|
<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>
|
||||||
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
<a href="/events" role="button" class="outline" id="filter-clear-all">Clear all</a>
|
||||||
<button type="submit">Apply</button>
|
|
||||||
<a href="/events" role="button" class="outline">Clear Filters</a>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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 id="events-map"></div>
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
|
|
@ -566,4 +608,124 @@
|
||||||
})();
|
})();
|
||||||
</script>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -104,7 +104,8 @@ class TestEventsFeedFrontendAuthenticated:
|
||||||
|
|
||||||
assert result.status_code == 200
|
assert result.status_code == 200
|
||||||
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_events_since_until_filter(self):
|
async def test_events_since_until_filter(self):
|
||||||
|
|
@ -154,8 +155,9 @@ class TestEventsFeedFrontendAuthenticated:
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
||||||
context = call_kwargs.get("context", call_kwargs)
|
context = call_kwargs.get("context", call_kwargs)
|
||||||
assert context["filter_values"]["since"] == "2026-05-17T00:00:00"
|
# v0.7.1: the GUI uses time presets, but legacy since/until are still
|
||||||
assert context["filter_values"]["until"] == "2026-05-17T12:00:00"
|
# honored (JSON API + bookmarks); they parse without error and apply.
|
||||||
|
assert context["filter_error"] is None
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_events_region_filter(self):
|
async def test_events_region_filter(self):
|
||||||
|
|
@ -207,10 +209,10 @@ class TestEventsFeedFrontendAuthenticated:
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
|
||||||
context = call_kwargs.get("context", call_kwargs)
|
context = call_kwargs.get("context", call_kwargs)
|
||||||
assert context["filter_values"]["region_north"] == "49.5"
|
assert context["filter_state"]["region_north"] == "49.5"
|
||||||
assert context["filter_values"]["region_south"] == "31"
|
assert context["filter_state"]["region_south"] == "31"
|
||||||
assert context["filter_values"]["region_east"] == "-102"
|
assert context["filter_state"]["region_east"] == "-102"
|
||||||
assert context["filter_values"]["region_west"] == "-124.5"
|
assert context["filter_state"]["region_west"] == "-124.5"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_events_partial_region_shows_error_banner(self):
|
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.
|
# The list is exactly the registry, sorted by name (stable), no extras.
|
||||||
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
|
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():
|
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:
|
class TestPerAdapterRowPartials:
|
||||||
|
|
|
||||||
210
tests/test_events_filtering.py
Normal file
210
tests/test_events_filtering.py
Normal 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]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue