Merge pull request #57 from zvx-echo6/feat/telemetry-separation

feat(telemetry-separation): dedicated /telemetry tab split from /events by adapter data_class (v0.7.4)
This commit is contained in:
malice 2026-05-25 01:38:43 -06:00 committed by GitHub
commit 83c5ad6e6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 247 additions and 92 deletions

View file

@ -211,6 +211,7 @@ Optional class attributes (default to `None` / not-in-wizard):
| `requires_api_key` | `str \| None` | Key alias if an API key is required, else `None`. | | `requires_api_key` | `str \| None` | Key alias if an API key is required, else `None`. |
| `api_key_field` | `str \| None` | Names the `settings_schema` field that holds the api_key alias reference. The GUI renders this as a select populated from `config.api_keys`; the wizard validates it against the staged `api_keys` state. | | `api_key_field` | `str \| None` | Names the `settings_schema` field that holds the api_key alias reference. The GUI renders this as a select populated from `config.api_keys`; the wizard validates it against the staged `api_keys` state. |
| `wizard_order` | `int \| None` | Position in the setup wizard. `None` excludes the adapter from the wizard. | | `wizard_order` | `int \| None` | Position in the setup wizard. `None` excludes the adapter from the wizard. |
| `data_class` | `Literal["event", "telemetry"]` | GUI classification. `"event"` (default) = discrete events, shown on `/events`. `"telemetry"` = continuous/high-volume feeds (e.g. NWIS) shown on `/telemetry` instead, so they don't drown discrete-event signal. GUI-only — does not affect publishing or the `events.json` contract. |
### 4.2 Required methods ### 4.2 Required methods

View file

@ -2,7 +2,7 @@
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
from pydantic import BaseModel from pydantic import BaseModel
@ -48,6 +48,12 @@ class SourceAdapter(ABC):
latitude and longitude; the supervisor extracts them, runs registered latitude and longitude; the supervisor extracts them, runs registered
enrichers, and attaches results under Event.data["_enriched"].""" enrichers, and attaches results under Event.data["_enriched"]."""
data_class: Literal["event", "telemetry"] = "event"
"""How the GUI classifies this source. "event" (default) = discrete events,
shown on /events. "telemetry" = continuous/high-volume feeds (e.g. NWIS water
gauges) that would drown discrete-event signal; shown on /telemetry instead.
GUI-only does not affect publishing or the events.json contract."""
@abstractmethod @abstractmethod
async def poll(self) -> AsyncIterator[Event]: async def poll(self) -> AsyncIterator[Event]:
""" """

View file

@ -127,6 +127,9 @@ class NWISAdapter(SourceAdapter):
# Site lat/lon mirrored from Geo.centroid into event.data (see _build_event). # Site lat/lon mirrored from Geo.centroid into event.data (see _build_event).
enrichment_locations = [("latitude", "longitude")] enrichment_locations = [("latitude", "longitude")]
# Continuous high-volume water-gauge feed -> the /telemetry tab, not /events.
data_class = "telemetry"
def __init__( def __init__(
self, self,
config: AdapterConfig, config: AdapterConfig,

View file

@ -2703,17 +2703,30 @@ def _resolve_time(token: str | None, now: datetime):
return None, None, False, f"Unknown time preset: {token}" return None, None, False, f"Unknown time preset: {token}"
def _adapter_filter_options(): def _class_adapter_names(data_class: str) -> list[str]:
"""Registry-derived adapter names for a data_class ('event' | 'telemetry')."""
return sorted(
name for name, cls in discover_adapters().items()
if getattr(cls, "data_class", "event") == data_class
)
def _adapter_filter_options(data_class: str | None = None):
"""Registry-derived (flat, grouped) adapter options for the chip-picker. """Registry-derived (flat, grouped) adapter options for the chip-picker.
flat: [{name, display_name, color}] sorted by name (color by sorted index, flat: [{name, display_name, color}] sorted by name. grouped:
matching the map legend). grouped: [(group_label, [{value,label,color}])]. [(group_label, [{value,label,color}])]. Colors are keyed to the FULL sorted
registry (stable per-adapter across the /events and /telemetry tabs). When
data_class is given, only that class's adapters are returned.
""" """
reg = discover_adapters() reg = discover_adapters()
ordered = sorted(reg.values(), key=lambda c: c.name) ordered = sorted(reg.values(), key=lambda c: c.name)
color_by_name = { color_by_name = {
cls.name: EVENTS_PALETTE[i % len(EVENTS_PALETTE)] for i, cls in enumerate(ordered) cls.name: EVENTS_PALETTE[i % len(EVENTS_PALETTE)] for i, cls in enumerate(ordered)
} }
if data_class is not None:
ordered = [c for c in ordered if getattr(c, "data_class", "event") == data_class]
shown = {c.name for c in ordered}
flat = [ flat = [
{"name": cls.name, "display_name": cls.display_name, "color": color_by_name[cls.name]} {"name": cls.name, "display_name": cls.display_name, "color": color_by_name[cls.name]}
for cls in ordered for cls in ordered
@ -2722,7 +2735,7 @@ def _adapter_filter_options():
for group_label, names in ADAPTER_GROUPS.items(): for group_label, names in ADAPTER_GROUPS.items():
items = [ items = [
{"value": n, "label": reg[n].display_name, "color": color_by_name[n]} {"value": n, "label": reg[n].display_name, "color": color_by_name[n]}
for n in names if n in reg for n in names if n in reg and n in shown
] ]
if items: if items:
grouped.append((group_label, items)) grouped.append((group_label, items))
@ -2992,6 +3005,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
limit = parsed_params["limit"] limit = parsed_params["limit"]
q = parsed_params.get("q") q = parsed_params.get("q")
adapters = parsed_params.get("adapters") or [] adapters = parsed_params.get("adapters") or []
class_adapters = parsed_params.get("class_adapters") # data_class split (GUI tabs)
categories = parsed_params.get("categories") or [] categories = parsed_params.get("categories") or []
event_types = parsed_params.get("event_types") or [] event_types = parsed_params.get("event_types") or []
severities = parsed_params.get("severities") or [] severities = parsed_params.get("severities") or []
@ -3022,6 +3036,12 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
query_params.append(adapters) query_params.append(adapters)
param_idx += 1 param_idx += 1
if class_adapters is not None:
# data_class split (/events vs /telemetry); registry-derived name list.
conditions.append(f"adapter = ANY(${param_idx})")
query_params.append(class_adapters)
param_idx += 1
if categories: if categories:
conditions.append(f"category = ANY(${param_idx})") conditions.append(f"category = ANY(${param_idx})")
query_params.append(categories) query_params.append(categories)
@ -3250,59 +3270,33 @@ async def events_json(request: Request):
# --- Events feed frontend routes --- # --- Events feed frontend routes ---
@router.get("/events", response_class=HTMLResponse) async def _events_query(request: Request, data_class: str):
async def events_list(request: Request) -> HTMLResponse: """Shared parse + class-scoped fetch for the /events and /telemetry tabs.
"""Events feed page with filter form, table, and map."""
templates = _get_templates()
operator = getattr(request.state, "operator", None)
csrf_token = getattr(request.state, "csrf_token", "")
Returns (parsed, error, events, next_cursor, total, class_adapters).
The data_class split is registry-derived and injected as `class_adapters`,
which _fetch_events applies as an `adapter = ANY(...)` condition.
"""
params = request.query_params params = request.query_params
# Parse parameters (GUI defaults to Last 24h when no time filter is given).
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0) parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0)
class_adapters = _class_adapter_names(data_class)
if parsed is not None:
parsed["class_adapters"] = class_adapters
# Get system settings for map tiles + DISTINCT filter-option lists. events, next_cursor, total = [], None, 0
pool = get_pool() if not error:
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"
events = []
next_cursor = None
total = 0
if error:
# Validation error - show error banner but don't fail the page
pass
else:
result = await _fetch_events(parsed) result = await _fetch_events(parsed)
if result.error: if result.error:
error = result.error error = result.error
else: else:
events = result.events events, next_cursor, total = result.events, result.next_cursor, result.total or 0
next_cursor = result.next_cursor
total = result.total or 0
_decorate_table_events(events) _decorate_table_events(events)
return parsed, error, events, next_cursor, total, class_adapters
pagination = _build_pagination(total, (parsed or {}).get("offset") or 0,
(parsed or {}).get("limit") or 50) def _events_filter_state(parsed: dict | None, params) -> dict:
adapters_flat, adapters_grouped = _adapter_filter_options()
pstate = parsed or {} pstate = parsed or {}
filter_state = { return {
"q": pstate.get("q") or "", "q": pstate.get("q") or "",
"adapters": pstate.get("adapters") or [], "adapters": pstate.get("adapters") or [],
"categories": pstate.get("categories") or [], "categories": pstate.get("categories") or [],
@ -3316,8 +3310,46 @@ async def events_list(request: Request) -> HTMLResponse:
"map_filter": pstate.get("map_filter", False), "map_filter": pstate.get("map_filter", False),
"limit": str(pstate.get("limit", 50)), "limit": str(pstate.get("limit", 50)),
} }
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []
# Paginator links append offset; keep cursor + offset out of the carried qs.
async def _events_page(request: Request, data_class: str, base_path: str) -> HTMLResponse:
"""Full events/telemetry page (shared by /events and /telemetry)."""
templates = _get_templates()
operator = getattr(request.state, "operator", None)
csrf_token = getattr(request.state, "csrf_token", "")
params = request.query_params
parsed, error, events, next_cursor, total, class_adapters = \
await _events_query(request, data_class)
# System map tiles + DISTINCT filter-option lists, scoped to this data_class.
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 WHERE adapter = ANY($1) ORDER BY 1",
class_adapters,
)
all_categories = [r["category"] for r in cat_rows]
et_rows = await conn.fetch(
"SELECT DISTINCT split_part(category, '.', 1) AS et FROM events "
"WHERE adapter = ANY($1) ORDER BY 1",
class_adapters,
)
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"
pagination = _build_pagination(total, (parsed or {}).get("offset") or 0,
(parsed or {}).get("limit") or 50)
adapters_flat, adapters_grouped = _adapter_filter_options(data_class)
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) query_string = urlencode([(k, v) for k, v in _query_items(params)
if k not in ("cursor", "offset")]) if k not in ("cursor", "offset")])
@ -3327,6 +3359,7 @@ async def events_list(request: Request) -> HTMLResponse:
context={ context={
"operator": operator, "operator": operator,
"csrf_token": csrf_token, "csrf_token": csrf_token,
"base_path": base_path,
"events": events, "events": events,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"pagination": pagination, "pagination": pagination,
@ -3340,42 +3373,23 @@ async def events_list(request: Request) -> HTMLResponse:
"severity_order": SEVERITY_ORDER, "severity_order": SEVERITY_ORDER,
"time_presets": [(t, TIME_PRESET_LABELS[t]) for t in "time_presets": [(t, TIME_PRESET_LABELS[t]) for t in
("last_15m", "last_1h", "last_6h", "last_24h", "last_7d", "active", "all")], ("last_15m", "last_1h", "last_6h", "last_24h", "last_7d", "active", "all")],
"filter_state": filter_state, "filter_state": _events_filter_state(parsed, params),
"active_pills": active_pills, "active_pills": active_pills,
"query_string": query_string, "query_string": query_string,
}, },
) )
@router.get("/events/rows", response_class=HTMLResponse) async def _events_rows_fragment(request: Request, data_class: str, base_path: str) -> HTMLResponse:
async def events_rows(request: Request) -> HTMLResponse: """HTMX rows fragment (shared by /events/rows and /telemetry/rows)."""
"""HTMX fragment: events table rows only (no page chrome)."""
templates = _get_templates() templates = _get_templates()
params = request.query_params params = request.query_params
# Parse parameters (same GUI default as the page). parsed, error, events, next_cursor, total, _ = await _events_query(request, data_class)
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0)
events = []
next_cursor = None
total = 0
if error:
pass
else:
result = await _fetch_events(parsed)
if result.error:
error = result.error
else:
events = result.events
next_cursor = result.next_cursor
total = result.total or 0
_decorate_table_events(events)
pagination = _build_pagination(total, (parsed or {}).get("offset") or 0, pagination = _build_pagination(total, (parsed or {}).get("offset") or 0,
(parsed or {}).get("limit") or 50) (parsed or {}).get("limit") or 50)
adapters_flat, _ = _adapter_filter_options() adapters_flat, _ = _adapter_filter_options(data_class)
active_pills = _build_active_pills(parsed or {}, len(adapters_flat)) if parsed else [] 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) query_string = urlencode([(k, v) for k, v in _query_items(params)
if k not in ("cursor", "offset")]) if k not in ("cursor", "offset")])
@ -3384,6 +3398,7 @@ async def events_rows(request: Request) -> HTMLResponse:
request=request, request=request,
name="_events_rows.html", name="_events_rows.html",
context={ context={
"base_path": base_path,
"events": events, "events": events,
"next_cursor": next_cursor, "next_cursor": next_cursor,
"pagination": pagination, "pagination": pagination,
@ -3393,7 +3408,28 @@ async def events_rows(request: Request) -> HTMLResponse:
"oob_pills": True, # emit an out-of-band #active-pills update on swap "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), # Push the bookmarkable full-page URL (not the fragment path).
# so back/forward + bookmarking land on the same filtered view. response.headers["HX-Push-Url"] = base_path + "?" + str(request.url.query)
response.headers["HX-Push-Url"] = "/events?" + str(request.url.query)
return response return response
@router.get("/events", response_class=HTMLResponse)
async def events_list(request: Request) -> HTMLResponse:
"""Events feed page (data_class=event): filter form, table, and map."""
return await _events_page(request, "event", "/events")
@router.get("/events/rows", response_class=HTMLResponse)
async def events_rows(request: Request) -> HTMLResponse:
return await _events_rows_fragment(request, "event", "/events")
@router.get("/telemetry", response_class=HTMLResponse)
async def telemetry_list(request: Request) -> HTMLResponse:
"""Telemetry feed page (data_class=telemetry; e.g. NWIS). Same shape as /events."""
return await _events_page(request, "telemetry", "/telemetry")
@router.get("/telemetry/rows", response_class=HTMLResponse)
async def telemetry_rows(request: Request) -> HTMLResponse:
return await _events_rows_fragment(request, "telemetry", "/telemetry")

View file

@ -72,20 +72,21 @@
{# Real offset paginator (v0.7.3). Each link carries offset + the filter {# Real offset paginator (v0.7.3). Each link carries offset + the filter
query_string (which excludes cursor/offset); limit persists via query_string. #} query_string (which excludes cursor/offset); limit persists via query_string. #}
{% macro page_link(off, label, cls, extra) %} {# base_path passed into the macro (macros don't see render-context vars). #}
{% macro page_link(base, off, label, cls) %}
{% set qs = "offset=" ~ off ~ ("&" ~ query_string if query_string else "") %} {% set qs = "offset=" ~ off ~ ("&" ~ query_string if query_string else "") %}
<a href="/events?{{ qs }}" role="button" class="page-link {{ cls }}" <a href="{{ base }}?{{ qs }}" role="button" class="page-link {{ cls }}"
hx-get="/events/rows?{{ qs }}" hx-target="#events-rows" hx-push-url="true" {{ extra }}>{{ label }}</a> hx-get="{{ base }}/rows?{{ qs }}" hx-target="#events-rows" hx-push-url="true">{{ label }}</a>
{% endmacro %} {% endmacro %}
<nav class="paginator" aria-label="Pagination"> <nav class="paginator" aria-label="Pagination">
<div class="paginator-pages"> <div class="paginator-pages">
{% if pagination.prev_offset is not none %}{{ page_link(pagination.prev_offset, " Previous", "page-prev", "") }}{% else %}<span class="page-link disabled"> Previous</span>{% endif %} {% if pagination.prev_offset is not none %}{{ page_link(base_path, pagination.prev_offset, " Previous", "page-prev") }}{% else %}<span class="page-link disabled"> Previous</span>{% endif %}
{% for p in pagination.pages %} {% for p in pagination.pages %}
{% if p.ellipsis %}<span class="page-ellipsis"></span> {% if p.ellipsis %}<span class="page-ellipsis"></span>
{% elif p.current %}<span class="page-link current" aria-current="page">{{ p.page }}</span> {% elif p.current %}<span class="page-link current" aria-current="page">{{ p.page }}</span>
{% else %}{{ page_link(p.offset, p.page, "", "") }}{% endif %} {% else %}{{ page_link(base_path, p.offset, p.page, "") }}{% endif %}
{% endfor %} {% endfor %}
{% if pagination.next_offset is not none %}{{ page_link(pagination.next_offset, "Next ", "page-next", "") }}{% else %}<span class="page-link disabled">Next </span>{% endif %} {% if pagination.next_offset is not none %}{{ page_link(base_path, pagination.next_offset, "Next ", "page-next") }}{% else %}<span class="page-link disabled">Next </span>{% endif %}
</div> </div>
<div class="paginator-meta"> <div class="paginator-meta">
{% if pagination.total %} {% if pagination.total %}

View file

@ -18,6 +18,7 @@
<li><a href="/">Dashboard</a></li> <li><a href="/">Dashboard</a></li>
<li><a href="/adapters">Adapters</a></li> <li><a href="/adapters">Adapters</a></li>
<li><a href="/events">Events</a></li> <li><a href="/events">Events</a></li>
<li><a href="/telemetry">Telemetry</a></li>
<li><a href="/streams">Streams</a></li> <li><a href="/streams">Streams</a></li>
<li><a href="/enrichment">Enrichment</a></li> <li><a href="/enrichment">Enrichment</a></li>
<li><a href="/api-keys">API Keys</a></li> <li><a href="/api-keys">API Keys</a></li>

View file

@ -203,7 +203,7 @@
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777", "#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488" "#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488"
] %} ] %}
<h1>Events</h1> <h1>{{ "Telemetry" if base_path == "/telemetry" else "Events" }}</h1>
{% if filter_error %} {% if filter_error %}
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;"> <article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
@ -212,8 +212,8 @@
{% endif %} {% endif %}
{% from "_chip_picker.html" import chip_picker %} {% from "_chip_picker.html" import chip_picker %}
<form id="filter-form" class="filter-form" action="/events" method="get" <form id="filter-form" class="filter-form" action="{{ base_path }}" method="get"
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true"> hx-get="{{ base_path }}/rows" hx-target="#events-rows" hx-push-url="true">
{# Full-width search (server-side ILIKE over subject + location). #} {# Full-width search (server-side ILIKE over subject + location). #}
<input type="search" id="filter-q" name="q" class="filter-search" <input type="search" id="filter-q" name="q" class="filter-search"
@ -261,7 +261,7 @@
<div class="filter-actions"> <div class="filter-actions">
<button type="submit" class="filter-apply">Apply</button> <button type="submit" class="filter-apply">Apply</button>
<a href="/events" role="button" class="outline" id="filter-clear-all">Clear all</a> <a href="{{ base_path }}" role="button" class="outline" id="filter-clear-all">Clear all</a>
</div> </div>
</form> </form>
@ -749,6 +749,7 @@
(function () { (function () {
var form = document.getElementById("filter-form"); var form = document.getElementById("filter-form");
if (!form) return; if (!form) return;
var BASE_PATH = {{ base_path | tojson }}; // "/events" or "/telemetry"
function submitForm() { if (window.htmx) htmx.trigger(form, "submit"); } function submitForm() { if (window.htmx) htmx.trigger(form, "submit"); }
@ -859,7 +860,7 @@
submitForm(); submitForm();
return; return;
} }
if (e.target.closest("[data-clear-all]")) { window.location.href = "/events"; } if (e.target.closest("[data-clear-all]")) { window.location.href = BASE_PATH; }
}); });
// Legend: collapse/expand toggle. // Legend: collapse/expand toggle.

View file

@ -710,6 +710,7 @@ def _events_context(events):
return { return {
"events": events, "events": events,
"next_cursor": None, "next_cursor": None,
"base_path": "/events",
"query_string": "", "query_string": "",
"pagination": { "pagination": {
"total": n, "offset": 0, "limit": 50, "page": 1, "total_pages": 1, "total": n, "offset": 0, "limit": 50, "page": 1, "total_pages": 1,
@ -781,13 +782,17 @@ class TestRegistryDrivenAdapterFilter:
context = mock_templates.TemplateResponse.call_args.kwargs.get("context") context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
# The list is exactly the registry, sorted by name (stable), no extras. # v0.7.4: /events shows event-class adapters only (telemetry-class, e.g.
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys()) # nwis, moved to /telemetry). Registry-derived, sorted, no extras.
event_names = sorted(n for n, c in registry.items()
if getattr(c, "data_class", "event") == "event")
assert [a["name"] for a in context["adapters"]] == event_names
# Each entry carries name + display_name (v0.7.1 adds a positional color). # Each entry carries name + display_name (v0.7.1 adds a positional color).
by_name = {a["name"]: a for a in context["adapters"]} by_name = {a["name"]: a for a in context["adapters"]}
for cls in registry.values(): for name in event_names:
assert by_name[cls.name]["display_name"] == cls.display_name cls = registry[name]
assert by_name[cls.name]["color"].startswith("#") assert by_name[name]["display_name"] == cls.display_name
assert by_name[name]["color"].startswith("#")
class TestPerAdapterRowPartials: class TestPerAdapterRowPartials:

View file

@ -0,0 +1,101 @@
"""Tests for v0.7.4 telemetry/event separation: SourceAdapter.data_class,
registry split, class-scoped filter options, and the data_class SQL filter.
Registry-derived (no hardcoded adapter lists beyond the nwis pin). No live DB.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from central.adapter import SourceAdapter
from central.adapter_discovery import discover_adapters
from central.gui import routes
# --- data_class defaults / registry split -----------------------------------
def test_base_default_is_event():
assert SourceAdapter.data_class == "event"
def test_registry_split_11_event_1_telemetry():
reg = discover_adapters()
by_class = {}
for name, cls in reg.items():
by_class.setdefault(getattr(cls, "data_class", "event"), []).append(name)
assert by_class.get("telemetry") == ["nwis"]
# Everything else is event-class; the split must cover the whole registry.
assert sorted(by_class.get("event", [])) == sorted(n for n in reg if n != "nwis")
assert len(by_class.get("event", [])) == len(reg) - 1
def test_class_adapter_names():
assert "nwis" not in routes._class_adapter_names("event")
assert routes._class_adapter_names("telemetry") == ["nwis"]
assert "usgs_quake" in routes._class_adapter_names("event")
# --- class-scoped chip-picker / legend options -------------------------------
def test_event_options_exclude_nwis():
flat, grouped = routes._adapter_filter_options("event")
names = {a["name"] for a in flat}
assert "nwis" not in names
assert len(flat) == len(discover_adapters()) - 1
grouped_values = {opt["value"] for _, items in grouped for opt in items}
assert "nwis" not in grouped_values
def test_telemetry_options_only_nwis():
flat, grouped = routes._adapter_filter_options("telemetry")
assert [a["name"] for a in flat] == ["nwis"]
grouped_values = [opt["value"] for _, items in grouped for opt in items]
assert grouped_values == ["nwis"]
def test_colors_stable_across_classes():
"""A given adapter keeps the same color on /events and /telemetry (colors
are keyed to the full registry, not the per-tab subset)."""
full, _ = routes._adapter_filter_options()
full_color = {a["name"]: a["color"] for a in full}
ev, _ = routes._adapter_filter_options("event")
for a in ev:
assert a["color"] == full_color[a["name"]]
# --- data_class SQL filter (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_class_adapters_adds_adapter_any_condition():
parsed, _ = routes._parse_events_params({"time": "all"}, default_offset=0)
parsed["class_adapters"] = routes._class_adapter_names("event")
cap = await _capture(parsed)
assert "adapter = ANY($" in cap["query"]
assert routes._class_adapter_names("event") in cap["params"]
@pytest.mark.asyncio
async def test_no_class_adapters_no_class_condition():
"""events.json path: no class_adapters -> no extra adapter filter (all classes)."""
parsed, _ = routes._parse_events_params({"time": "all"}) # cursor-mode, no class
assert parsed.get("class_adapters") is None
cap = await _capture(parsed)
# The only adapter=ANY would come from a user filter, which we didn't set.
assert "adapter = ANY($" not in cap["query"]