mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +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 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
|
||||
|
|
|
|||
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">
|
||||
<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 %}&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"
|
||||
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-push-url="true">
|
||||
Next →
|
||||
|
|
@ -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 %}
|
||||
|
|
|
|||
|
|
@ -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 %}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue