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 %} -
- 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.
-