diff --git a/sql/migrations/042_monitoring_area_to_multi_areas.sql b/sql/migrations/042_monitoring_area_to_multi_areas.sql new file mode 100644 index 0000000..77d2754 --- /dev/null +++ b/sql/migrations/042_monitoring_area_to_multi_areas.sql @@ -0,0 +1,48 @@ +-- Migration 042: generalize the single system bbox into a named multi-area list (v0.14.0) +-- +-- Until now the system monitoring area was a single rectangle stored as four +-- columns on the config.system singleton (monitor_north/south/east/west, added +-- by migration 030, default widened to full Idaho by 034). Matt needs to watch +-- several non-contiguous regions (Treasure Valley, Magic Valley, Mountain Home, +-- ...) without inflating one box to cover everything between them. This migration +-- introduces config.monitoring_areas: a list of named bboxes with set-union +-- semantics -- an event is kept by the archive (and at publish time) if its +-- geometry intersects ANY area in the list. An empty list keeps everything, +-- matching the pre-feature "no area configured" default. +-- +-- The existing single bbox is preserved verbatim as the row named 'default', +-- carrying Matt's current production bounds (N=49.0 S=41.8 E=-111.0 W=-117.5). +-- +-- The old config.system.monitor_* columns are intentionally LEFT IN PLACE for +-- v0.14.0 (consumers stop reading them; the GUI tile_url/attribution still live +-- on config.system). They are dropped in a follow-up (v0.14.1) once the new +-- table is proven on prod. Idempotent per docs/migrations.md. + +CREATE TABLE IF NOT EXISTS config.monitoring_areas ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + north DOUBLE PRECISION NOT NULL, + south DOUBLE PRECISION NOT NULL, + east DOUBLE PRECISION NOT NULL, + west DOUBLE PRECISION NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + CONSTRAINT monitoring_areas_lat_order CHECK (north > south), + CONSTRAINT monitoring_areas_lon_order CHECK (east > west) +); + +CREATE INDEX IF NOT EXISTS monitoring_areas_name_idx ON config.monitoring_areas(name); + +-- Seed the 'default' area from the existing single bbox so current bounds are +-- preserved on upgrade. Skipped cleanly if the row already exists (re-run) or if +-- any monitor_* column is NULL (no usable bbox -> nothing to migrate, list stays +-- empty = keep everything). +INSERT INTO config.monitoring_areas (name, north, south, east, west) +SELECT 'default', monitor_north, monitor_south, monitor_east, monitor_west +FROM config.system +WHERE id = true + AND monitor_north IS NOT NULL + AND monitor_south IS NOT NULL + AND monitor_east IS NOT NULL + AND monitor_west IS NOT NULL +ON CONFLICT (name) DO NOTHING; diff --git a/src/central/archive.py b/src/central/archive.py index a28494f..f448e91 100644 --- a/src/central/archive.py +++ b/src/central/archive.py @@ -23,8 +23,8 @@ from central.monitoring_area import ( MONITORING_AREA_REFRESH_S, MonitoringArea, build_geom_json, - classify_geom, - load_monitoring_area, + classify_geom_areas, + load_monitoring_areas, ) from central.streams import STREAMS as STREAM_REGISTRY @@ -87,9 +87,21 @@ class ArchiveConsumer: self._js: JetStreamContext | None = None self._pool: asyncpg.Pool | None = None self._shutdown_event = asyncio.Event() - self._monitoring_area: MonitoringArea | None = None + self._monitoring_areas: list[MonitoringArea] = [] self._dropped: dict[str, int] = {} + @property + def _monitoring_area(self) -> MonitoringArea | None: + """Back-compat single-area accessor (pre-v0.14.0): the first area, or None. + + v0.14.0 holds a list internally; this property keeps single-area callers + and tests working. Setting it wraps the value into a one-element list.""" + return self._monitoring_areas[0] if self._monitoring_areas else None + + @_monitoring_area.setter + def _monitoring_area(self, area: MonitoringArea | None) -> None: + self._monitoring_areas = [area] if area is not None else [] + async def connect(self) -> None: """Connect to NATS and PostgreSQL.""" self._nc = await nats.connect(self._nats_url) @@ -116,7 +128,7 @@ class ArchiveConsumer: logger.info("Disconnected") async def _load_monitoring_area(self) -> None: - """Load (or refresh) the system monitoring-area bbox from config.system. + """Load (or refresh) the system monitoring areas from config.monitoring_areas. On any error keep the last-known value and warn -- the filter must never block archiving because a config read failed.""" @@ -124,7 +136,7 @@ class ArchiveConsumer: return try: async with self._pool.acquire() as conn: - self._monitoring_area = await load_monitoring_area(conn) + self._monitoring_areas = await load_monitoring_areas(conn) except Exception as e: logger.warning( "Could not load monitoring area; keeping previous value", @@ -237,7 +249,7 @@ class ArchiveConsumer: geom_json = build_geom_json(geo_data) - verdict = classify_geom(geom_json, self._monitoring_area) + verdict = classify_geom_areas(geom_json, self._monitoring_areas) if verdict == "out-of-bounds": self._dropped[adapter] = self._dropped.get(adapter, 0) + 1 logger.debug( @@ -354,13 +366,16 @@ class ArchiveConsumer: await self.connect() await self._cleanup_orphaned_consumer() await self._load_monitoring_area() - area = self._monitoring_area logger.info( "Archive consumer ready", - extra={"monitoring_area": ( - {"north": area.north, "south": area.south, - "east": area.east, "west": area.west} if area else None - )}, + extra={ + "monitoring_areas": len(self._monitoring_areas), + "bounds": [ + {"north": a.north, "south": a.south, + "east": a.east, "west": a.west} + for a in self._monitoring_areas + ], + }, ) async def run(self) -> None: diff --git a/src/central/config_store.py b/src/central/config_store.py index cb949df..8fe35fc 100644 --- a/src/central/config_store.py +++ b/src/central/config_store.py @@ -14,7 +14,7 @@ import asyncpg from central.config_models import AdapterConfig, EnrichmentConfig, StreamConfig from central.crypto import decrypt, encrypt -from central.monitoring_area import MonitoringArea, load_monitoring_area +from central.monitoring_area import MonitoringArea, load_monitoring_areas logger = logging.getLogger(__name__) @@ -66,15 +66,25 @@ class ConfigStore: # System configuration # ------------------------------------------------------------------------- - async def get_monitoring_area(self) -> MonitoringArea | None: - """Read the system monitoring-area bbox from ``config.system``. + async def get_monitoring_areas(self) -> list[MonitoringArea]: + """Read the system monitoring areas from ``config.monitoring_areas`` (v0.14.0). - Returns ``None`` if no row is set or any monitor_* column is NULL. - Used by both archive (for the INSERT-time filter) and supervisor (for - the publish-time filter, v0.10.2). + Returns every configured area (empty list = keep everything). Used by + both archive (INSERT-time filter) and supervisor (publish-time filter) + to apply set-union bbox semantics. """ async with self._pool.acquire() as conn: - return await load_monitoring_area(conn) + return await load_monitoring_areas(conn) + + async def get_monitoring_area(self) -> MonitoringArea | None: + """Single-area back-compat shim (pre-v0.14.0): the first configured area. + + Reads the new ``config.monitoring_areas`` table and returns its first row + (by name) or ``None`` when the list is empty, mirroring the old + ``config.system`` single-bbox read for any lingering single-area caller. + """ + areas = await self.get_monitoring_areas() + return areas[0] if areas else None # ------------------------------------------------------------------------- # Adapter configuration diff --git a/src/central/gui/audit.py b/src/central/gui/audit.py index b7cfd47..520fffc 100644 --- a/src/central/gui/audit.py +++ b/src/central/gui/audit.py @@ -15,6 +15,9 @@ API_KEY_CREATE = "api_key.create" API_KEY_ROTATE = "api_key.rotate" API_KEY_DELETE = "api_key.delete" SYSTEM_UPDATE = "system.update" +MONITORING_AREA_CREATE = "monitoring_area.create" +MONITORING_AREA_UPDATE = "monitoring_area.update" +MONITORING_AREA_DELETE = "monitoring_area.delete" SETUP_COMPLETE = "setup.complete" diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index b46a1c1..235f80b 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -44,6 +44,9 @@ from central.gui.audit import ( AUTH_LOGIN_FAILED, AUTH_LOGOUT, AUTH_PASSWORD_CHANGE, + MONITORING_AREA_CREATE, + MONITORING_AREA_DELETE, + MONITORING_AREA_UPDATE, OPERATOR_CREATE, SETUP_COMPLETE, STREAM_UPDATE, @@ -2386,72 +2389,51 @@ async def enrichment_update(request: Request) -> Response: # --- Monitoring area (system-level archive bbox filter) -------------------- +# v0.14.0: monitoring areas are a named list (config.monitoring_areas) with +# set-union semantics -- an event is kept if it intersects ANY area. The legacy +# single bbox lived on config.system.monitor_* (still present, unused as of +# v0.14.0); _DEFAULT_MONITOR stays here as the "Add area" seed + regression guard. _DEFAULT_MONITOR = {"north": 49.0, "south": 41.8, "east": -111.0, "west": -117.5} +_DEFAULT_TILE = { + "tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "tile_attribution": "© OpenStreetMap contributors", +} -async def _read_monitoring_area(conn) -> dict[str, Any]: - """Read the monitoring-area bbox + map tile settings from config.system.""" +async def _read_tile_settings(conn) -> dict[str, str]: + """Map tile URL + attribution (shared GUI map settings on config.system).""" row = await conn.fetchrow( - "SELECT monitor_north, monitor_south, monitor_east, monitor_west, " - "map_tile_url, map_attribution FROM config.system WHERE id = true" + "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true" ) - if row is None: - return { - **_DEFAULT_MONITOR, - "tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - "tile_attribution": "© OpenStreetMap contributors", - } - return { - "north": row["monitor_north"], "south": row["monitor_south"], - "east": row["monitor_east"], "west": row["monitor_west"], - "tile_url": row["map_tile_url"], - "tile_attribution": row["map_attribution"], - } + if row is None or not row["map_tile_url"]: + return dict(_DEFAULT_TILE) + return {"tile_url": row["map_tile_url"], "tile_attribution": row["map_attribution"]} -@router.get("/monitoring-area", response_class=HTMLResponse) -async def monitoring_area_form(request: Request) -> HTMLResponse: - """Render the system monitoring-area editor (one draggable Leaflet rectangle). - - Events whose geometry falls entirely outside this box are dropped by the - archive-level bbox filter; null-geom events are always kept.""" - templates = _get_templates() - pool = get_pool() - async with pool.acquire() as conn: - area = await _read_monitoring_area(conn) - return templates.TemplateResponse( - request=request, - name="monitoring_area.html", - context={ - "operator": getattr(request.state, "operator", None), - "csrf_token": request.state.csrf_token, - "area": area, - "tile_url": area["tile_url"], - "tile_attribution": area["tile_attribution"], - }, +async def _read_monitoring_areas(conn) -> list[dict[str, Any]]: + """Read every configured monitoring area (v0.14.0 set-union bbox filter).""" + rows = await conn.fetch( + "SELECT id, name, north, south, east, west " + "FROM config.monitoring_areas ORDER BY name" ) + return [dict(r) for r in rows] -@router.post("/monitoring-area") -async def monitoring_area_update(request: Request) -> Response: - """Validate + persist the monitoring-area bbox. The archive applies the new - bounds within ~60s via its background refresh (no restart needed).""" - templates = _get_templates() - pool = get_pool() - - form = await request.form() - if not form.get("csrf_token") or form.get("csrf_token") != request.state.csrf_token: - raise CsrfValidationError("Invalid CSRF token") - +def _validate_area_form(form) -> tuple[dict[str, Any], dict[str, str]]: + """Validate a monitoring-area create/update form -> (values, errors).""" errors: dict[str, str] = {} - vals: dict[str, float] = {} + vals: dict[str, Any] = {} + name = (form.get("name") or "").strip() + if not name: + errors["name"] = "Name is required" + else: + vals["name"] = name for key, lo, hi in ( ("north", -90.0, 90.0), ("south", -90.0, 90.0), ("east", -180.0, 180.0), ("west", -180.0, 180.0), ): - raw = form.get(f"monitor_{key}", "") try: - v = float(raw) + v = float(form.get(key, "")) except (TypeError, ValueError): errors[key] = f"{key.title()} must be a number" continue @@ -2459,56 +2441,162 @@ async def monitoring_area_update(request: Request) -> Response: errors[key] = f"{key.title()} must be between {lo:g} and {hi:g}" else: vals[key] = v + if vals.get("north") is not None and vals.get("south") is not None and \ + vals["north"] <= vals["south"]: + errors["north"] = "North must be greater than South" + if vals.get("east") is not None and vals.get("west") is not None and \ + vals["east"] <= vals["west"]: + errors["east"] = "East must be greater than West" + return vals, errors - if not errors: - if vals["north"] <= vals["south"]: - errors["north"] = "North must be greater than South" - if vals["east"] <= vals["west"]: - errors["east"] = "East must be greater than West" - if errors: - async with pool.acquire() as conn: - saved = await _read_monitoring_area(conn) - render_area = { - "north": form.get("monitor_north") or saved["north"], - "south": form.get("monitor_south") or saved["south"], - "east": form.get("monitor_east") or saved["east"], - "west": form.get("monitor_west") or saved["west"], - } - return templates.TemplateResponse( - request=request, - name="monitoring_area.html", - context={ - "operator": getattr(request.state, "operator", None), - "csrf_token": request.state.csrf_token, - "area": render_area, - "tile_url": saved["tile_url"], - "tile_attribution": saved["tile_attribution"], - "errors": errors, - }, - status_code=200, - ) +async def _render_monitoring_areas( + request, templates, conn, *, error=None, form_values=None, + edit_id=None, status=200, +) -> HTMLResponse: + """Render the monitoring-areas page; reused by GET and the error paths.""" + areas = await _read_monitoring_areas(conn) + tile = await _read_tile_settings(conn) + return templates.TemplateResponse( + request=request, + name="monitoring_area.html", + context={ + "operator": getattr(request.state, "operator", None), + "csrf_token": request.state.csrf_token, + "areas": areas, + "tile_url": tile["tile_url"], + "tile_attribution": tile["tile_attribution"], + "default_bounds": _DEFAULT_MONITOR, + "error": error, + "form_values": form_values, + "edit_id": edit_id, + }, + status_code=status, + ) + +def _require_csrf(form, request) -> None: + if not form.get("csrf_token") or form.get("csrf_token") != request.state.csrf_token: + raise CsrfValidationError("Invalid CSRF token") + + +@router.get("/monitoring-area", response_class=HTMLResponse) +async def monitoring_area_list(request: Request) -> HTMLResponse: + """List the configured monitoring areas on a shared map. + + An event is archived/published if its geometry intersects ANY area; no-geom + events are always kept; an empty list keeps every event. The archive + + supervisor pick up edits within ~60s via their background refresh.""" + templates = _get_templates() + pool = get_pool() async with pool.acquire() as conn: - old = await conn.fetchrow( - "SELECT monitor_north, monitor_south, monitor_east, monitor_west " - "FROM config.system WHERE id = true" - ) - await conn.execute( - "UPDATE config.system SET monitor_north=$1, monitor_south=$2, " - "monitor_east=$3, monitor_west=$4 WHERE id = true", - vals["north"], vals["south"], vals["east"], vals["west"], - ) + return await _render_monitoring_areas(request, templates, conn) + + +@router.post("/monitoring-area") +async def monitoring_area_create(request: Request) -> Response: + """Create a new named monitoring area.""" + from asyncpg.exceptions import UniqueViolationError + templates = _get_templates() + pool = get_pool() + form = await request.form() + _require_csrf(form, request) + vals, errors = _validate_area_form(form) + async with pool.acquire() as conn: + if errors: + return await _render_monitoring_areas( + request, templates, conn, error="; ".join(errors.values()), + form_values=dict(form), + ) + try: + await conn.execute( + "INSERT INTO config.monitoring_areas (name, north, south, east, west) " + "VALUES ($1, $2, $3, $4, $5)", + vals["name"], vals["north"], vals["south"], vals["east"], vals["west"], + ) + except UniqueViolationError: + return await _render_monitoring_areas( + request, templates, conn, + error=f"An area named '{vals['name']}' already exists.", + form_values=dict(form), + ) operator = getattr(request.state, "operator", None) await write_audit( - conn, SYSTEM_UPDATE, + conn, MONITORING_AREA_CREATE, operator_id=operator.id if operator else None, - target="monitoring_area", - before=dict(old) if old else None, - after={"monitor_north": vals["north"], "monitor_south": vals["south"], - "monitor_east": vals["east"], "monitor_west": vals["west"]}, + target=f"monitoring_area:{vals['name']}", after=vals, ) + return RedirectResponse(url="/monitoring-area", status_code=302) + +@router.post("/monitoring-area/{area_id}/update") +async def monitoring_area_update(request: Request, area_id: int) -> Response: + """Update an existing monitoring area's name and/or bounds.""" + from asyncpg.exceptions import UniqueViolationError + templates = _get_templates() + pool = get_pool() + form = await request.form() + _require_csrf(form, request) + vals, errors = _validate_area_form(form) + async with pool.acquire() as conn: + if errors: + return await _render_monitoring_areas( + request, templates, conn, error="; ".join(errors.values()), + form_values=dict(form), edit_id=area_id, + ) + old = await conn.fetchrow( + "SELECT name, north, south, east, west " + "FROM config.monitoring_areas WHERE id = $1", area_id, + ) + if old is None: + return await _render_monitoring_areas( + request, templates, conn, + error="That area no longer exists.", status=404, + ) + try: + await conn.execute( + "UPDATE config.monitoring_areas SET name=$1, north=$2, south=$3, " + "east=$4, west=$5, updated_at=now() WHERE id=$6", + vals["name"], vals["north"], vals["south"], vals["east"], + vals["west"], area_id, + ) + except UniqueViolationError: + return await _render_monitoring_areas( + request, templates, conn, + error=f"An area named '{vals['name']}' already exists.", + form_values=dict(form), edit_id=area_id, + ) + operator = getattr(request.state, "operator", None) + await write_audit( + conn, MONITORING_AREA_UPDATE, + operator_id=operator.id if operator else None, + target=f"monitoring_area:{vals['name']}", + before=dict(old), after=vals, + ) + return RedirectResponse(url="/monitoring-area", status_code=302) + + +@router.post("/monitoring-area/{area_id}/delete") +async def monitoring_area_delete(request: Request, area_id: int) -> Response: + """Delete a monitoring area. Removing the last one keeps every event.""" + pool = get_pool() + form = await request.form() + _require_csrf(form, request) + async with pool.acquire() as conn: + old = await conn.fetchrow( + "SELECT name, north, south, east, west " + "FROM config.monitoring_areas WHERE id = $1", area_id, + ) + await conn.execute( + "DELETE FROM config.monitoring_areas WHERE id = $1", area_id, + ) + if old is not None: + operator = getattr(request.state, "operator", None) + await write_audit( + conn, MONITORING_AREA_DELETE, + operator_id=operator.id if operator else None, + target=f"monitoring_area:{old['name']}", before=dict(old), + ) return RedirectResponse(url="/monitoring-area", status_code=302) diff --git a/src/central/gui/templates/monitoring_area.html b/src/central/gui/templates/monitoring_area.html index 15c0b21..94af73c 100644 --- a/src/central/gui/templates/monitoring_area.html +++ b/src/central/gui/templates/monitoring_area.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Central — Monitoring Area{% endblock %} +{% block title %}Central — Monitoring Areas{% endblock %} {% block head %} @@ -10,136 +10,192 @@ {% endblock %} {% block content %} -

Monitoring Area

+

Monitoring Areas

- Events whose geometry falls entirely outside this box are dropped by the - archive before they reach the events table. Events with no geometry (e.g. - space-weather alerts, removal tombstones) are always kept. Changes apply - within about a minute — no restart required. + An event is archived (and published to subscribers) if its geometry intersects + any area below — the areas form a set-union, so add as many + non-contiguous regions as you need. Events with no geometry (space-weather + alerts, removal tombstones) are always kept. With no areas configured, every + event is kept. Changes apply within about a minute — no restart required.

-
+{% if error %}
{{ error }}
{% endif %} +{% if not areas %} +
No areas configured — archive will keep every event.
+{% endif %} + +
+ + + + - -
- -
- -
-
- - - {% if errors and errors.north %}{{ errors.north }}{% endif %} -
-
- - - {% if errors and errors.south %}{{ errors.south }}{% endif %} -
-
- - - {% if errors and errors.east %}{{ errors.east }}{% endif %} -
-
- - - {% if errors and errors.west %}{{ errors.west }}{% endif %} -
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
- - -
- -
-
+

Drag the rectangle's corners on the map to set the bounds.

+ + +{% if areas %} +
+ + + + {% for a in areas %} + + + + + + + + {% endfor %} + +
NameBounds (N, S, E, W)
{{ a.name }}{{ "%.3f"|format(a.north) }}, {{ "%.3f"|format(a.south) }}, {{ "%.3f"|format(a.east) }}, {{ "%.3f"|format(a.west) }} +
+ + +
+
+
+{% endif %} + + + + + + {% endblock %} diff --git a/src/central/monitoring_area.py b/src/central/monitoring_area.py index c52dc39..723fe9c 100644 --- a/src/central/monitoring_area.py +++ b/src/central/monitoring_area.py @@ -80,31 +80,49 @@ def build_geom_json(geo_data: dict[str, Any] | None) -> str | None: return None -def classify_geom(geom_json: str | None, area: MonitoringArea | None) -> str: - """Classify a GeoJSON string against the monitoring area. +def classify_geom_areas( + geom_json: str | None, areas: list[MonitoringArea] +) -> str: + """Classify a GeoJSON string against a list of monitoring areas (set union). Returns one of: 'null-geom' -- no geometry; always kept (SWPC trio, .removed tombstones) - 'no-area' -- no monitoring area configured; keep everything - 'in-bounds' -- geometry intersects the area; keep - 'out-of-bounds' -- geometry lies entirely outside the area; drop + 'no-area' -- empty area list; keep everything (pre-feature default) + 'in-bounds' -- geometry intersects AT LEAST ONE area; keep + 'out-of-bounds' -- geometry lies entirely outside every area; drop 'invalid-geom' -- geometry could not be evaluated; kept (fail-open) + warn + v0.14.0 generalizes the single-bbox ``classify_geom``: an event is kept if it + intersects ANY configured area. Areas may overlap. An empty list means "no + area configured" and keeps everything, exactly as a NULL single bbox did. + Uses ``intersects()`` so border-straddlers and points-on-edge are kept (matches PostGIS ``ST_Intersects``). The filter must never drop an event because of a parse failure -- when in doubt, keep it. """ if geom_json is None: return "null-geom" - if area is None: + if not areas: return "no-area" try: geom = shape(json.loads(geom_json)) - return "in-bounds" if geom.intersects(area.as_box()) else "out-of-bounds" + for area in areas: + if geom.intersects(area.as_box()): + return "in-bounds" + return "out-of-bounds" except Exception: return "invalid-geom" +def classify_geom(geom_json: str | None, area: MonitoringArea | None) -> str: + """Single-area back-compat shim over :func:`classify_geom_areas` (v0.14.0). + + Preserves the pre-v0.14.0 signature and verdicts byte-for-byte: ``None`` area + -> ``'no-area'`` (an empty list), a present area -> a one-element list. + """ + return classify_geom_areas(geom_json, [area] if area is not None else []) + + async def load_monitoring_area(conn: asyncpg.Connection) -> MonitoringArea | None: """Read the system monitoring area from ``config.system``. @@ -128,3 +146,26 @@ async def load_monitoring_area(conn: asyncpg.Connection) -> MonitoringArea | Non west=row["monitor_west"], ) return None + + +async def load_monitoring_areas(conn: asyncpg.Connection) -> list[MonitoringArea]: + """Read the system monitoring areas from ``config.monitoring_areas`` (v0.14.0). + + Returns a list of every configured area, ordered by name for stable logging + and rendering. An empty list (no rows) means "no area configured" -> the + filter keeps everything (see :func:`classify_geom_areas`). + + Column names (north/south/east/west) match the ``MonitoringArea`` fields so + the row maps straight onto the dataclass. Callers own pool acquisition and + any exception handling (archive/supervisor keep the last-known value on a + read failure and warn). + """ + rows = await conn.fetch( + "SELECT north, south, east, west FROM config.monitoring_areas ORDER BY name" + ) + return [ + MonitoringArea( + north=r["north"], south=r["south"], east=r["east"], west=r["west"] + ) + for r in rows + ] diff --git a/src/central/supervisor.py b/src/central/supervisor.py index 7c56b54..407f0c2 100644 --- a/src/central/supervisor.py +++ b/src/central/supervisor.py @@ -35,7 +35,7 @@ from central.monitoring_area import ( MONITORING_AREA_REFRESH_S, MonitoringArea, build_geom_json, - classify_geom, + classify_geom_areas, ) from central.stream_manager import StreamManager from central.streams import STREAMS as STREAM_REGISTRY @@ -243,9 +243,21 @@ class Supervisor: # ACK-but-don't-insert behavior at the supervisor->NATS hop so # subscribers (meshai, Navi) never see out-of-area events. Refreshed # every MONITORING_AREA_REFRESH_S from config.system. - self._monitoring_area: MonitoringArea | None = None + self._monitoring_areas: list[MonitoringArea] = [] self._dropped_publish: dict[str, int] = {} + @property + def _monitoring_area(self) -> MonitoringArea | None: + """Back-compat single-area accessor (pre-v0.14.0): the first area, or None. + + v0.14.0 holds a list internally; this property keeps single-area callers + and tests working. Setting it wraps the value into a one-element list.""" + return self._monitoring_areas[0] if self._monitoring_areas else None + + @_monitoring_area.setter + def _monitoring_area(self, area: MonitoringArea | None) -> None: + self._monitoring_areas = [area] if area is not None else [] + async def connect(self) -> None: """Connect to NATS.""" self._nc = await nats.connect(self._nats_url) @@ -257,19 +269,22 @@ class Supervisor: # on failure keep the last value and warn -- never block startup over a # config read. try: - self._monitoring_area = await self._config_store.get_monitoring_area() + self._monitoring_areas = await self._config_store.get_monitoring_areas() except Exception as e: logger.warning( - "Could not load monitoring area at startup; publish filter no-ops until refresh", + "Could not load monitoring areas at startup; publish filter no-ops until refresh", extra={"error": str(e)}, ) - area = self._monitoring_area logger.info( - "Publish-time monitoring area loaded", - extra={"monitoring_area": ( - {"north": area.north, "south": area.south, - "east": area.east, "west": area.west} if area else None - )}, + "Publish-time monitoring areas loaded", + extra={ + "monitoring_areas": len(self._monitoring_areas), + "bounds": [ + {"north": a.north, "south": a.south, + "east": a.east, "west": a.west} + for a in self._monitoring_areas + ], + }, ) async def disconnect(self) -> None: @@ -313,7 +328,7 @@ class Supervisor: ) except asyncio.TimeoutError: try: - self._monitoring_area = await self._config_store.get_monitoring_area() + self._monitoring_areas = await self._config_store.get_monitoring_areas() except Exception as e: logger.warning( "Could not refresh monitoring area; keeping previous value", @@ -419,7 +434,7 @@ class Supervisor: geom_json = build_geom_json( event.geo.model_dump() if event.geo else None ) - verdict = classify_geom(geom_json, self._monitoring_area) + verdict = classify_geom_areas(geom_json, self._monitoring_areas) if verdict == "out-of-bounds": self._dropped_publish[state.name] = ( self._dropped_publish.get(state.name, 0) + 1 diff --git a/tests/test_archive_bbox_filter.py b/tests/test_archive_bbox_filter.py index be25e68..e4a51ea 100644 --- a/tests/test_archive_bbox_filter.py +++ b/tests/test_archive_bbox_filter.py @@ -80,3 +80,40 @@ class TestProcessMessageFilter: await c._process_message(msg, conn) conn.execute.assert_awaited_once() assert c._dropped == {} + + +# NYC metro box -- disjoint from IDAHO, for set-union (v0.14.0) coverage. +NYC_BOX = MonitoringArea(north=41.0, south=40.3, east=-73.5, west=-74.5) + + +class TestProcessMessageMultiArea: + """v0.14.0: archive keeps an event if it intersects ANY configured area.""" + + @pytest.mark.asyncio + async def test_dropped_when_outside_the_only_configured_area(self): + # Boise event, but only the NYC area is configured -> dropped. + c = ArchiveConsumer("nats://x", "postgresql://x") + c._monitoring_areas = [NYC_BOX] + conn = AsyncMock() + await c._process_message(_make_msg(_envelope("nws", -114.0, 43.5)), conn) + conn.execute.assert_not_called() + assert c._dropped == {"nws": 1} + + @pytest.mark.asyncio + async def test_kept_when_inside_any_of_several_areas(self): + # Same Boise event, but IDAHO is also configured -> kept via union. + c = ArchiveConsumer("nats://x", "postgresql://x") + c._monitoring_areas = [NYC_BOX, IDAHO] + conn = AsyncMock() + await c._process_message(_make_msg(_envelope("nws", -114.0, 43.5)), conn) + conn.execute.assert_awaited_once() + assert c._dropped == {} + + @pytest.mark.asyncio + async def test_empty_list_keeps_everything(self): + c = ArchiveConsumer("nats://x", "postgresql://x") + c._monitoring_areas = [] + conn = AsyncMock() + await c._process_message(_make_msg(_envelope("wzdx", -74.0, 40.7)), conn) + conn.execute.assert_awaited_once() + assert c._dropped == {} diff --git a/tests/test_gui_monitoring_area.py b/tests/test_gui_monitoring_area.py new file mode 100644 index 0000000..d981623 --- /dev/null +++ b/tests/test_gui_monitoring_area.py @@ -0,0 +1,146 @@ +"""v0.14.0 monitoring-areas GUI routes: list / create / update / delete. + +Server-rendered forms (matching the rest of the GUI), so these call the route +handlers directly with a mock pool + request and assert status + side effects. +""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from asyncpg.exceptions import UniqueViolationError +from fastapi.responses import RedirectResponse + +from central.gui.routes import ( + monitoring_area_create, + monitoring_area_delete, + monitoring_area_list, + monitoring_area_update, +) + +_AREA_ROW = {"id": 1, "name": "treasure_valley", + "north": 44.0, "south": 43.0, "east": -115.5, "west": -116.5} + + +class _Tmpl: + """Stand-in for Jinja templates -- echoes status + context for assertions.""" + def TemplateResponse(self, **kw): + return SimpleNamespace( + status_code=kw.get("status_code", 200), context=kw["context"]) + + +def _conn(*, fetch=None, fetchrow=None, execute_error=None): + c = MagicMock() + c.fetch = AsyncMock(return_value=fetch if fetch is not None else []) + c.fetchrow = AsyncMock(return_value=fetchrow) + c.execute = AsyncMock(side_effect=execute_error) + return c + + +def _pool(conn): + pool = MagicMock() + cm = MagicMock() + cm.__aenter__ = AsyncMock(return_value=conn) + cm.__aexit__ = AsyncMock(return_value=False) + pool.acquire = MagicMock(return_value=cm) + return pool + + +def _req(form=None): + r = MagicMock() + r.state.csrf_token = "tok" + r.state.operator = SimpleNamespace(id=1, username="admin") + + async def _form(): + return form or {} + r.form = _form + return r + + +def _form(**over): + base = {"csrf_token": "tok", "name": "magic_valley", + "north": "43.0", "south": "42.3", "east": "-113.4", "west": "-114.9"} + base.update(over) + return base + + +def _patches(conn): + return ( + patch("central.gui.routes.get_pool", return_value=_pool(conn)), + patch("central.gui.routes._get_templates", return_value=_Tmpl()), + patch("central.gui.routes.write_audit", new=AsyncMock()), + ) + + +@pytest.mark.asyncio +class TestList: + async def test_renders_areas(self): + conn = _conn(fetch=[_AREA_ROW], fetchrow=None) + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_list(_req()) + assert res.status_code == 200 + assert res.context["areas"] == [_AREA_ROW] + + +@pytest.mark.asyncio +class TestCreate: + async def test_valid_redirects_and_inserts(self): + conn = _conn() + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_create(_req(_form())) + assert isinstance(res, RedirectResponse) and res.status_code == 302 + assert "INSERT INTO config.monitoring_areas" in conn.execute.call_args[0][0] + + async def test_invalid_name_rerenders_no_insert(self): + conn = _conn() + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_create(_req(_form(name=""))) + assert res.status_code == 200 and res.context["error"] + conn.execute.assert_not_called() + + async def test_inverted_bounds_rerenders(self): + conn = _conn() + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_create(_req(_form(north="42.0", south="43.0"))) + assert res.status_code == 200 and res.context["error"] + conn.execute.assert_not_called() + + async def test_duplicate_name_rerenders(self): + conn = _conn(execute_error=UniqueViolationError("dup")) + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_create(_req(_form())) + assert res.status_code == 200 and "already exists" in res.context["error"] + + +@pytest.mark.asyncio +class TestUpdate: + async def test_valid_redirects_and_updates(self): + conn = _conn(fetchrow=_AREA_ROW) + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_update(_req(_form()), 1) + assert isinstance(res, RedirectResponse) and res.status_code == 302 + assert "UPDATE config.monitoring_areas" in conn.execute.call_args[0][0] + + async def test_missing_id_returns_404(self): + conn = _conn(fetchrow=None) + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_update(_req(_form()), 999) + assert res.status_code == 404 + + +@pytest.mark.asyncio +class TestDelete: + async def test_redirects_and_deletes(self): + conn = _conn(fetchrow=_AREA_ROW) + p1, p2, p3 = _patches(conn) + with p1, p2, p3: + res = await monitoring_area_delete(_req({"csrf_token": "tok"}), 1) + assert isinstance(res, RedirectResponse) and res.status_code == 302 + assert "DELETE FROM config.monitoring_areas" in conn.execute.call_args[0][0] diff --git a/tests/test_migration_042.py b/tests/test_migration_042.py new file mode 100644 index 0000000..3a1902f --- /dev/null +++ b/tests/test_migration_042.py @@ -0,0 +1,40 @@ +"""v0.14.0 migration 042: single config.system bbox -> config.monitoring_areas. + +The suite has no live Postgres (it runs identically as zvx or central), so this +asserts the migration's shape statically: it creates the table, enforces the +union-filter invariants, seeds 'default' from the existing config.system bbox to +preserve current bounds, and -- deliberately for v0.14.0 -- does NOT drop the old +columns (that lands in v0.14.1). +""" + +from pathlib import Path + +_SQL = Path("sql/migrations/042_monitoring_area_to_multi_areas.sql").read_text() +_NORM = " ".join(_SQL.split()) # whitespace-insensitive matching + + +def test_creates_monitoring_areas_table(): + assert "CREATE TABLE IF NOT EXISTS config.monitoring_areas" in _NORM + + +def test_name_is_unique(): + assert "name TEXT NOT NULL UNIQUE" in _NORM + + +def test_check_constraints_enforce_bbox_ordering(): + assert "CHECK (north > south)" in _NORM + assert "CHECK (east > west)" in _NORM + + +def test_seeds_default_from_config_system_preserving_bounds(): + assert "INSERT INTO config.monitoring_areas" in _NORM + assert "FROM config.system" in _NORM + assert "'default'" in _NORM + # Idempotent re-run / already-seeded installs must not error or duplicate. + assert "ON CONFLICT (name) DO NOTHING" in _NORM + + +def test_does_not_drop_old_columns_in_v0_14_0(): + upper = _NORM.upper() + assert "DROP COLUMN" not in upper + assert "DROP TABLE" not in upper diff --git a/tests/test_monitoring_area.py b/tests/test_monitoring_area.py index abbfae8..39f8e14 100644 --- a/tests/test_monitoring_area.py +++ b/tests/test_monitoring_area.py @@ -20,10 +20,14 @@ from central.monitoring_area import ( MonitoringArea, build_geom_json, classify_geom, + classify_geom_areas, load_monitoring_area, + load_monitoring_areas, ) IDAHO = MonitoringArea(north=44.5, south=41.8, east=-111.0, west=-117.5) +# A second, disjoint area to exercise set-union semantics (the NYC metro box). +NYC_BOX = MonitoringArea(north=41.0, south=40.3, east=-73.5, west=-74.5) def _pt(lon, lat): @@ -117,6 +121,55 @@ class TestClassifyGeom: assert classify_geom(json.dumps({"type": "Nonsense"}), IDAHO) == "invalid-geom" +class TestClassifyGeomAreas: + """v0.14.0 set-union: keep if the geometry intersects ANY area.""" + + def test_empty_list_keeps_everything(self): + assert classify_geom_areas(_pt(-74.0, 40.7), []) == "no-area" + + def test_null_geom_always_kept(self): + assert classify_geom_areas(None, [IDAHO]) == "null-geom" + assert classify_geom_areas(None, []) == "null-geom" + + def test_single_area_matches_single_bbox_behavior(self): + # Same verdicts as the legacy single-area classify_geom for a 1-list. + assert classify_geom_areas(_pt(-114.0, 43.5), [IDAHO]) == "in-bounds" + assert classify_geom_areas(_pt(-74.0, 40.7), [IDAHO]) == "out-of-bounds" + + def test_kept_if_in_either_area(self): + # Boise -> IDAHO; NYC -> NYC_BOX. Union keeps both. + assert classify_geom_areas(_pt(-114.0, 43.5), [IDAHO, NYC_BOX]) == "in-bounds" + assert classify_geom_areas(_pt(-74.0, 40.7), [IDAHO, NYC_BOX]) == "in-bounds" + + def test_dropped_only_when_outside_every_area(self): + # London is in neither box. + assert classify_geom_areas(_pt(-0.13, 51.5), [IDAHO, NYC_BOX]) == "out-of-bounds" + + def test_invalid_geom_fails_open(self): + assert classify_geom_areas("{bad json", [IDAHO]) == "invalid-geom" + + def test_overlapping_areas_still_in_bounds(self): + overlap = MonitoringArea(north=44.0, south=42.0, east=-112.0, west=-118.0) + assert classify_geom_areas(_pt(-114.0, 43.0), [IDAHO, overlap]) == "in-bounds" + + +@pytest.mark.asyncio +class TestLoadMonitoringAreas: + async def test_returns_all_rows_as_areas(self): + conn = MagicMock() + conn.fetch = AsyncMock(return_value=[ + {"north": 44.5, "south": 41.8, "east": -111.0, "west": -117.5}, + {"north": 41.0, "south": 40.3, "east": -73.5, "west": -74.5}, + ]) + areas = await load_monitoring_areas(conn) + assert areas == [IDAHO, NYC_BOX] + + async def test_empty_table_returns_empty_list(self): + conn = MagicMock() + conn.fetch = AsyncMock(return_value=[]) + assert await load_monitoring_areas(conn) == [] + + @pytest.mark.asyncio class TestLoadMonitoringArea: async def test_returns_area_when_all_columns_set(self): diff --git a/tests/test_supervisor_publish_filter.py b/tests/test_supervisor_publish_filter.py index bb58145..0a936ac 100644 --- a/tests/test_supervisor_publish_filter.py +++ b/tests/test_supervisor_publish_filter.py @@ -71,6 +71,9 @@ def sup_factory(): store.set_adapter_last_error = AsyncMock() store.get_api_key = AsyncMock(return_value=None) store.get_monitoring_area = AsyncMock(return_value=area) + store.get_monitoring_areas = AsyncMock( + return_value=[area] if area is not None else [] + ) config_source = MagicMock() config_source.get_enrichment_config = AsyncMock(return_value=EnrichmentConfig()) sup = sup_mod.Supervisor( @@ -173,7 +176,7 @@ async def test_refresh_loop_reloads_area_and_logs_summary( sup = sup_factory(None) sup._dropped_publish = {"mock": 7} monkeypatch.setattr(sup_mod, "MONITORING_AREA_REFRESH_S", 0.05) - sup._config_store.get_monitoring_area = AsyncMock(return_value=IDAHO) + sup._config_store.get_monitoring_areas = AsyncMock(return_value=[IDAHO]) with caplog.at_level(logging.INFO): task = asyncio.create_task(sup._refresh_monitoring_area_loop()) await asyncio.sleep(0.15)