v0.14.0: multi-bbox monitoring areas (config.monitoring_areas set-union filter)

Generalize the single config.system bbox into a named config.monitoring_areas list with set-union semantics (kept if geometry intersects ANY area; no-geom always kept; empty list keeps everything). Migration 042 seeds 'default' from the existing bounds (49.0/41.8/-111/-117.5); old monitor_* columns preserved for v0.14.1. Archive + supervisor both apply the list. GUI /monitoring-area gains list/create/update/delete + multi-rectangle map.
This commit is contained in:
malice 2026-06-11 13:17:54 -06:00 committed by GitHub
commit c2864a94ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 792 additions and 237 deletions

View file

@ -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;

View file

@ -23,8 +23,8 @@ from central.monitoring_area import (
MONITORING_AREA_REFRESH_S, MONITORING_AREA_REFRESH_S,
MonitoringArea, MonitoringArea,
build_geom_json, build_geom_json,
classify_geom, classify_geom_areas,
load_monitoring_area, load_monitoring_areas,
) )
from central.streams import STREAMS as STREAM_REGISTRY from central.streams import STREAMS as STREAM_REGISTRY
@ -87,9 +87,21 @@ class ArchiveConsumer:
self._js: JetStreamContext | None = None self._js: JetStreamContext | None = None
self._pool: asyncpg.Pool | None = None self._pool: asyncpg.Pool | None = None
self._shutdown_event = asyncio.Event() self._shutdown_event = asyncio.Event()
self._monitoring_area: MonitoringArea | None = None self._monitoring_areas: list[MonitoringArea] = []
self._dropped: dict[str, int] = {} 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: async def connect(self) -> None:
"""Connect to NATS and PostgreSQL.""" """Connect to NATS and PostgreSQL."""
self._nc = await nats.connect(self._nats_url) self._nc = await nats.connect(self._nats_url)
@ -116,7 +128,7 @@ class ArchiveConsumer:
logger.info("Disconnected") logger.info("Disconnected")
async def _load_monitoring_area(self) -> None: 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 On any error keep the last-known value and warn -- the filter must never
block archiving because a config read failed.""" block archiving because a config read failed."""
@ -124,7 +136,7 @@ class ArchiveConsumer:
return return
try: try:
async with self._pool.acquire() as conn: 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: except Exception as e:
logger.warning( logger.warning(
"Could not load monitoring area; keeping previous value", "Could not load monitoring area; keeping previous value",
@ -237,7 +249,7 @@ class ArchiveConsumer:
geom_json = build_geom_json(geo_data) 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": if verdict == "out-of-bounds":
self._dropped[adapter] = self._dropped.get(adapter, 0) + 1 self._dropped[adapter] = self._dropped.get(adapter, 0) + 1
logger.debug( logger.debug(
@ -354,13 +366,16 @@ class ArchiveConsumer:
await self.connect() await self.connect()
await self._cleanup_orphaned_consumer() await self._cleanup_orphaned_consumer()
await self._load_monitoring_area() await self._load_monitoring_area()
area = self._monitoring_area
logger.info( logger.info(
"Archive consumer ready", "Archive consumer ready",
extra={"monitoring_area": ( extra={
{"north": area.north, "south": area.south, "monitoring_areas": len(self._monitoring_areas),
"east": area.east, "west": area.west} if area else None "bounds": [
)}, {"north": a.north, "south": a.south,
"east": a.east, "west": a.west}
for a in self._monitoring_areas
],
},
) )
async def run(self) -> None: async def run(self) -> None:

View file

@ -14,7 +14,7 @@ import asyncpg
from central.config_models import AdapterConfig, EnrichmentConfig, StreamConfig from central.config_models import AdapterConfig, EnrichmentConfig, StreamConfig
from central.crypto import decrypt, encrypt 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__) logger = logging.getLogger(__name__)
@ -66,15 +66,25 @@ class ConfigStore:
# System configuration # System configuration
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
async def get_monitoring_area(self) -> MonitoringArea | None: async def get_monitoring_areas(self) -> list[MonitoringArea]:
"""Read the system monitoring-area bbox from ``config.system``. """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. Returns every configured area (empty list = keep everything). Used by
Used by both archive (for the INSERT-time filter) and supervisor (for both archive (INSERT-time filter) and supervisor (publish-time filter)
the publish-time filter, v0.10.2). to apply set-union bbox semantics.
""" """
async with self._pool.acquire() as conn: 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 # Adapter configuration

View file

@ -15,6 +15,9 @@ API_KEY_CREATE = "api_key.create"
API_KEY_ROTATE = "api_key.rotate" API_KEY_ROTATE = "api_key.rotate"
API_KEY_DELETE = "api_key.delete" API_KEY_DELETE = "api_key.delete"
SYSTEM_UPDATE = "system.update" 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" SETUP_COMPLETE = "setup.complete"

View file

@ -44,6 +44,9 @@ from central.gui.audit import (
AUTH_LOGIN_FAILED, AUTH_LOGIN_FAILED,
AUTH_LOGOUT, AUTH_LOGOUT,
AUTH_PASSWORD_CHANGE, AUTH_PASSWORD_CHANGE,
MONITORING_AREA_CREATE,
MONITORING_AREA_DELETE,
MONITORING_AREA_UPDATE,
OPERATOR_CREATE, OPERATOR_CREATE,
SETUP_COMPLETE, SETUP_COMPLETE,
STREAM_UPDATE, STREAM_UPDATE,
@ -2386,72 +2389,51 @@ async def enrichment_update(request: Request) -> Response:
# --- Monitoring area (system-level archive bbox filter) -------------------- # --- 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_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]: async def _read_tile_settings(conn) -> dict[str, str]:
"""Read the monitoring-area bbox + map tile settings from config.system.""" """Map tile URL + attribution (shared GUI map settings on config.system)."""
row = await conn.fetchrow( row = await conn.fetchrow(
"SELECT monitor_north, monitor_south, monitor_east, monitor_west, " "SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
"map_tile_url, map_attribution FROM config.system WHERE id = true"
) )
if row is None: if row is None or not row["map_tile_url"]:
return { return dict(_DEFAULT_TILE)
**_DEFAULT_MONITOR, return {"tile_url": row["map_tile_url"], "tile_attribution": row["map_attribution"]}
"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"],
}
@router.get("/monitoring-area", response_class=HTMLResponse) async def _read_monitoring_areas(conn) -> list[dict[str, Any]]:
async def monitoring_area_form(request: Request) -> HTMLResponse: """Read every configured monitoring area (v0.14.0 set-union bbox filter)."""
"""Render the system monitoring-area editor (one draggable Leaflet rectangle). rows = await conn.fetch(
"SELECT id, name, north, south, east, west "
Events whose geometry falls entirely outside this box are dropped by the "FROM config.monitoring_areas ORDER BY name"
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"],
},
) )
return [dict(r) for r in rows]
@router.post("/monitoring-area") def _validate_area_form(form) -> tuple[dict[str, Any], dict[str, str]]:
async def monitoring_area_update(request: Request) -> Response: """Validate a monitoring-area create/update form -> (values, errors)."""
"""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")
errors: dict[str, str] = {} 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 ( for key, lo, hi in (
("north", -90.0, 90.0), ("south", -90.0, 90.0), ("north", -90.0, 90.0), ("south", -90.0, 90.0),
("east", -180.0, 180.0), ("west", -180.0, 180.0), ("east", -180.0, 180.0), ("west", -180.0, 180.0),
): ):
raw = form.get(f"monitor_{key}", "")
try: try:
v = float(raw) v = float(form.get(key, ""))
except (TypeError, ValueError): except (TypeError, ValueError):
errors[key] = f"{key.title()} must be a number" errors[key] = f"{key.title()} must be a number"
continue 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}" errors[key] = f"{key.title()} must be between {lo:g} and {hi:g}"
else: else:
vals[key] = v 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 def _render_monitoring_areas(
async with pool.acquire() as conn: request, templates, conn, *, error=None, form_values=None,
saved = await _read_monitoring_area(conn) edit_id=None, status=200,
render_area = { ) -> HTMLResponse:
"north": form.get("monitor_north") or saved["north"], """Render the monitoring-areas page; reused by GET and the error paths."""
"south": form.get("monitor_south") or saved["south"], areas = await _read_monitoring_areas(conn)
"east": form.get("monitor_east") or saved["east"], tile = await _read_tile_settings(conn)
"west": form.get("monitor_west") or saved["west"], return templates.TemplateResponse(
} request=request,
return templates.TemplateResponse( name="monitoring_area.html",
request=request, context={
name="monitoring_area.html", "operator": getattr(request.state, "operator", None),
context={ "csrf_token": request.state.csrf_token,
"operator": getattr(request.state, "operator", None), "areas": areas,
"csrf_token": request.state.csrf_token, "tile_url": tile["tile_url"],
"area": render_area, "tile_attribution": tile["tile_attribution"],
"tile_url": saved["tile_url"], "default_bounds": _DEFAULT_MONITOR,
"tile_attribution": saved["tile_attribution"], "error": error,
"errors": errors, "form_values": form_values,
}, "edit_id": edit_id,
status_code=200, },
) 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: async with pool.acquire() as conn:
old = await conn.fetchrow( return await _render_monitoring_areas(request, templates, conn)
"SELECT monitor_north, monitor_south, monitor_east, monitor_west "
"FROM config.system WHERE id = true"
) @router.post("/monitoring-area")
await conn.execute( async def monitoring_area_create(request: Request) -> Response:
"UPDATE config.system SET monitor_north=$1, monitor_south=$2, " """Create a new named monitoring area."""
"monitor_east=$3, monitor_west=$4 WHERE id = true", from asyncpg.exceptions import UniqueViolationError
vals["north"], vals["south"], vals["east"], vals["west"], 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) operator = getattr(request.state, "operator", None)
await write_audit( await write_audit(
conn, SYSTEM_UPDATE, conn, MONITORING_AREA_CREATE,
operator_id=operator.id if operator else None, operator_id=operator.id if operator else None,
target="monitoring_area", target=f"monitoring_area:{vals['name']}", after=vals,
before=dict(old) if old else None,
after={"monitor_north": vals["north"], "monitor_south": vals["south"],
"monitor_east": vals["east"], "monitor_west": vals["west"]},
) )
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) return RedirectResponse(url="/monitoring-area", status_code=302)

View file

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Central — Monitoring Area{% endblock %} {% block title %}Central — Monitoring Areas{% endblock %}
{% block head %} {% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""> <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="">
@ -10,136 +10,192 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<h1>Monitoring Area</h1> <h1>Monitoring Areas</h1>
<p class="muted"> <p class="muted">
Events whose geometry falls entirely outside this box are dropped by the An event is archived (and published to subscribers) if its geometry intersects
archive before they reach the events table. Events with no geometry (e.g. <strong>any</strong> area below — the areas form a set-union, so add as many
space-weather alerts, removal tombstones) are always kept. Changes apply non-contiguous regions as you need. Events with no geometry (space-weather
within about a minute — no restart required. alerts, removal tombstones) are always kept. With no areas configured, every
event is kept. Changes apply within about a minute — no restart required.
</p> </p>
<form method="post" action="/monitoring-area"> {% if error %}<div class="flash flash-error">{{ error }}</div>{% endif %}
{% if not areas %}
<div class="flash flash-warn">No areas configured — archive will keep every event.</div>
{% endif %}
<div id="area-map" style="height: 440px; margin-bottom: 1rem; border: 1px solid var(--rule); border-radius: var(--radius);"></div>
<button type="button" id="add-area-btn" class="btn-contrast">Add area</button>
<form id="area-editor" method="post" action="/monitoring-area" style="display:none; margin-top: 1rem;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="cols">
<div id="region-picker-container" <div>
data-north="{{ area.north }}" <label for="ed-name">Name</label>
data-south="{{ area.south }}" <input type="text" id="ed-name" name="name" required maxlength="64" autocomplete="off">
data-east="{{ area.east }}" </div>
data-west="{{ area.west }}" <div>
data-tile-url="{{ tile_url }}" <label for="ed-north">North</label>
data-tile-attr="{{ tile_attribution }}"> <input type="number" id="ed-north" name="north" step="0.0001" min="-90" max="90" readonly>
</div>
<div id="region-map" style="height: 420px; margin-bottom: 1rem; border: 1px solid var(--rule); border-radius: var(--radius);"></div> <div>
<label for="ed-south">South</label>
<div class="cols"> <input type="number" id="ed-south" name="south" step="0.0001" min="-90" max="90" readonly>
<div> </div>
<label for="monitor_north">North</label> <div>
<input type="number" id="monitor_north" name="monitor_north" step="0.0001" min="-90" max="90" readonly value="{{ area.north }}"> <label for="ed-east">East</label>
{% if errors and errors.north %}<small class="field-error">{{ errors.north }}</small>{% endif %} <input type="number" id="ed-east" name="east" step="0.0001" min="-180" max="180" readonly>
</div> </div>
<div> <div>
<label for="monitor_south">South</label> <label for="ed-west">West</label>
<input type="number" id="monitor_south" name="monitor_south" step="0.0001" min="-90" max="90" readonly value="{{ area.south }}"> <input type="number" id="ed-west" name="west" step="0.0001" min="-180" max="180" readonly>
{% if errors and errors.south %}<small class="field-error">{{ errors.south }}</small>{% endif %}
</div>
<div>
<label for="monitor_east">East</label>
<input type="number" id="monitor_east" name="monitor_east" step="0.0001" min="-180" max="180" readonly value="{{ area.east }}">
{% if errors and errors.east %}<small class="field-error">{{ errors.east }}</small>{% endif %}
</div>
<div>
<label for="monitor_west">West</label>
<input type="number" id="monitor_west" name="monitor_west" step="0.0001" min="-180" max="180" readonly value="{{ area.west }}">
{% if errors and errors.west %}<small class="field-error">{{ errors.west }}</small>{% endif %}
</div>
</div> </div>
<button type="button" id="region-reset-btn" class="btn-secondary" style="margin-top: 12px;">Reset to Saved</button>
</div>
<div style="margin-top: 1rem;">
<button type="submit" class="btn-primary">Save Monitoring Area</button>
</div> </div>
<p class="muted" style="margin-top:.5rem;">Drag the rectangle's corners on the map to set the bounds.</p>
<button type="submit" id="ed-save" class="btn-contrast">Create area</button>
<button type="button" id="ed-cancel" class="btn-secondary">Cancel</button>
</form> </form>
{% if areas %}
<div class="table-wrap" style="margin-top: 1.5rem;">
<table>
<thead><tr><th></th><th>Name</th><th>Bounds (N, S, E, W)</th><th></th><th></th></tr></thead>
<tbody>
{% for a in areas %}
<tr data-id="{{ a.id }}" data-name="{{ a.name }}">
<td><span class="area-swatch" data-name="{{ a.name }}" style="display:inline-block;width:14px;height:14px;border-radius:3px;"></span></td>
<td>{{ a.name }}</td>
<td class="muted mono">{{ "%.3f"|format(a.north) }}, {{ "%.3f"|format(a.south) }}, {{ "%.3f"|format(a.east) }}, {{ "%.3f"|format(a.west) }}</td>
<td><button type="button" class="btn-outline edit-btn" data-id="{{ a.id }}" style="height:30px;padding:0 12px;">Edit</button></td>
<td>
<form method="post" action="/monitoring-area/{{ a.id }}/delete" onsubmit="return confirm('Delete area &quot;{{ a.name }}&quot;?');" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="btn-danger" style="height:30px;padding:0 12px;">Delete</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<script id="areas-data" type="application/json">{{ areas | tojson }}</script>
<script id="defaults-data" type="application/json">{{ default_bounds | tojson }}</script>
<script id="formvalues-data" type="application/json">{{ form_values | tojson }}</script>
<script id="editid-data" type="application/json">{{ edit_id | tojson }}</script>
<script> <script>
(function() { (function () {
const container = document.getElementById('region-picker-container'); const areas = JSON.parse(document.getElementById('areas-data').textContent || '[]');
const savedNorth = parseFloat(container.dataset.north); const defaults = JSON.parse(document.getElementById('defaults-data').textContent);
const savedSouth = parseFloat(container.dataset.south); const formValues = JSON.parse(document.getElementById('formvalues-data').textContent || 'null');
const savedEast = parseFloat(container.dataset.east); const editId = JSON.parse(document.getElementById('editid-data').textContent || 'null');
const savedWest = parseFloat(container.dataset.west); const tileUrl = {{ tile_url | tojson }};
const tileUrl = container.dataset.tileUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png'; const tileAttr = {{ tile_attribution | tojson }};
const tileAttr = container.dataset.tileAttr || '&copy; OpenStreetMap contributors';
const centerLat = (savedNorth + savedSouth) / 2; // Golden-angle hue per area name (same 137.508 step as events_list.html's
const centerLng = (savedEast + savedWest) / 2; // per-NORAD coloring) so areas render as well-spread, repeatable colors.
const map = L.map('region-map').setView([centerLat, centerLng], 5); function hueForName(name) {
let h = 0;
for (let i = 0; i < name.length; i++) { h = (h * 31 + name.charCodeAt(i)) >>> 0; }
return (h * 137.508) % 360;
}
function colorForName(name) { return 'hsl(' + hueForName(name).toFixed(1) + ', 70%, 50%)'; }
const map = L.map('area-map');
L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 18 }).addTo(map); L.tileLayer(tileUrl, { attribution: tileAttr, maxZoom: 18 }).addTo(map);
setTimeout(function() { map.invalidateSize(); }, 100); setTimeout(function () { map.invalidateSize(); }, 100);
const bounds = L.latLngBounds( // Draw every configured area as a colored, tooltipped rectangle.
L.latLng(savedSouth, savedWest), const rects = {};
L.latLng(savedNorth, savedEast) const group = L.featureGroup().addTo(map);
); areas.forEach(function (a) {
map.fitBounds(bounds.pad(0.1)); const r = L.rectangle([[a.south, a.west], [a.north, a.east]],
{ color: colorForName(a.name), weight: 2, fillOpacity: 0.15 });
let rectangle = L.rectangle(bounds, { color: '#3388ff', weight: 2, fillOpacity: 0.2 }); r.bindTooltip(a.name, { permanent: false });
r.addTo(group);
const drawnItems = new L.FeatureGroup(); rects[a.id] = r;
drawnItems.addLayer(rectangle);
map.addLayer(drawnItems);
const drawControl = new L.Control.Draw({
draw: {
rectangle: { shapeOptions: { color: '#3388ff', weight: 2, fillOpacity: 0.2 } },
polyline: false, polygon: false, circle: false, marker: false, circlemarker: false
},
edit: { featureGroup: drawnItems, edit: false, remove: false }
}); });
map.addControl(drawControl);
rectangle.editing.enable();
const northInput = document.getElementById('monitor_north'); // Auto-fit to all areas + 10% padding; fall back to the default seed bounds.
const southInput = document.getElementById('monitor_south'); if (areas.length) {
const eastInput = document.getElementById('monitor_east'); map.fitBounds(group.getBounds().pad(0.1));
const westInput = document.getElementById('monitor_west'); } else {
map.setView([(defaults.north + defaults.south) / 2,
function updateInputs() { (defaults.east + defaults.west) / 2], 5);
const b = rectangle.getBounds();
northInput.value = b.getNorth().toFixed(4);
southInput.value = b.getSouth().toFixed(4);
eastInput.value = b.getEast().toFixed(4);
westInput.value = b.getWest().toFixed(4);
} }
rectangle.on('edit', updateInputs); document.querySelectorAll('.area-swatch').forEach(function (s) {
s.style.background = colorForName(s.dataset.name);
map.on(L.Draw.Event.CREATED, function(e) {
drawnItems.clearLayers();
rectangle = e.layer;
rectangle.setStyle({ color: '#3388ff', weight: 2, fillOpacity: 0.2 });
drawnItems.addLayer(rectangle);
rectangle.editing.enable();
rectangle.on('edit', updateInputs);
updateInputs();
}); });
document.getElementById('region-reset-btn').addEventListener('click', function() { const editor = document.getElementById('area-editor');
const originalBounds = L.latLngBounds( const fName = document.getElementById('ed-name');
L.latLng(savedSouth, savedWest), const fN = document.getElementById('ed-north'), fS = document.getElementById('ed-south');
L.latLng(savedNorth, savedEast) const fE = document.getElementById('ed-east'), fW = document.getElementById('ed-west');
); const saveBtn = document.getElementById('ed-save');
drawnItems.clearLayers(); let editRect = null;
rectangle = L.rectangle(originalBounds, { color: '#3388ff', weight: 2, fillOpacity: 0.2 });
drawnItems.addLayer(rectangle); function syncInputs() {
rectangle.editing.enable(); const b = editRect.getBounds();
rectangle.on('edit', updateInputs); fN.value = b.getNorth().toFixed(4); fS.value = b.getSouth().toFixed(4);
updateInputs(); fE.value = b.getEast().toFixed(4); fW.value = b.getWest().toFixed(4);
}
function beginEdit(rect) {
if (editRect && editRect.editing) { editRect.editing.disable(); }
editRect = rect;
rect.editing.enable(); // leaflet.draw corner handles (no new dep)
rect.on('edit', syncInputs);
syncInputs();
}
function openEditor(action, label) {
editor.action = action; saveBtn.textContent = label; editor.style.display = 'block';
}
function closeEditor() {
if (rects['__new__']) { group.removeLayer(rects['__new__']); delete rects['__new__']; }
if (editRect && editRect.editing) { editRect.editing.disable(); }
editor.style.display = 'none';
}
document.getElementById('add-area-btn').addEventListener('click', function () {
if (rects['__new__']) { group.removeLayer(rects['__new__']); }
const c = map.getCenter();
const r = L.rectangle([[c.lat - 1, c.lng - 1], [c.lat + 1, c.lng + 1]],
{ color: '#3388ff', weight: 2, fillOpacity: 0.2 }).addTo(group);
rects['__new__'] = r;
fName.value = '';
openEditor('/monitoring-area', 'Create area');
beginEdit(r);
}); });
updateInputs(); document.querySelectorAll('.edit-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
const id = btn.dataset.id;
const tr = btn.closest('tr');
fName.value = tr.dataset.name;
openEditor('/monitoring-area/' + id + '/update', 'Save changes');
beginEdit(rects[id]);
map.fitBounds(rects[id].getBounds().pad(0.3));
});
});
document.getElementById('ed-cancel').addEventListener('click', closeEditor);
// Re-open the editor pre-filled after a server-side validation error.
if (formValues) {
if (editId !== null && document.querySelector('.edit-btn[data-id="' + editId + '"]')) {
document.querySelector('.edit-btn[data-id="' + editId + '"]').click();
} else {
document.getElementById('add-area-btn').click();
}
if (formValues.name) { fName.value = formValues.name; }
if (formValues.north) { fN.value = formValues.north; }
if (formValues.south) { fS.value = formValues.south; }
if (formValues.east) { fE.value = formValues.east; }
if (formValues.west) { fW.value = formValues.west; }
}
})(); })();
</script> </script>
{% endblock %} {% endblock %}

View file

@ -80,31 +80,49 @@ def build_geom_json(geo_data: dict[str, Any] | None) -> str | None:
return None return None
def classify_geom(geom_json: str | None, area: MonitoringArea | None) -> str: def classify_geom_areas(
"""Classify a GeoJSON string against the monitoring area. geom_json: str | None, areas: list[MonitoringArea]
) -> str:
"""Classify a GeoJSON string against a list of monitoring areas (set union).
Returns one of: Returns one of:
'null-geom' -- no geometry; always kept (SWPC trio, .removed tombstones) 'null-geom' -- no geometry; always kept (SWPC trio, .removed tombstones)
'no-area' -- no monitoring area configured; keep everything 'no-area' -- empty area list; keep everything (pre-feature default)
'in-bounds' -- geometry intersects the area; keep 'in-bounds' -- geometry intersects AT LEAST ONE area; keep
'out-of-bounds' -- geometry lies entirely outside the area; drop 'out-of-bounds' -- geometry lies entirely outside every area; drop
'invalid-geom' -- geometry could not be evaluated; kept (fail-open) + warn '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 Uses ``intersects()`` so border-straddlers and points-on-edge are kept
(matches PostGIS ``ST_Intersects``). The filter must never drop an event (matches PostGIS ``ST_Intersects``). The filter must never drop an event
because of a parse failure -- when in doubt, keep it. because of a parse failure -- when in doubt, keep it.
""" """
if geom_json is None: if geom_json is None:
return "null-geom" return "null-geom"
if area is None: if not areas:
return "no-area" return "no-area"
try: try:
geom = shape(json.loads(geom_json)) 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: except Exception:
return "invalid-geom" 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: async def load_monitoring_area(conn: asyncpg.Connection) -> MonitoringArea | None:
"""Read the system monitoring area from ``config.system``. """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"], west=row["monitor_west"],
) )
return None 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
]

View file

@ -35,7 +35,7 @@ from central.monitoring_area import (
MONITORING_AREA_REFRESH_S, MONITORING_AREA_REFRESH_S,
MonitoringArea, MonitoringArea,
build_geom_json, build_geom_json,
classify_geom, classify_geom_areas,
) )
from central.stream_manager import StreamManager from central.stream_manager import StreamManager
from central.streams import STREAMS as STREAM_REGISTRY 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 # ACK-but-don't-insert behavior at the supervisor->NATS hop so
# subscribers (meshai, Navi) never see out-of-area events. Refreshed # subscribers (meshai, Navi) never see out-of-area events. Refreshed
# every MONITORING_AREA_REFRESH_S from config.system. # 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] = {} 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: async def connect(self) -> None:
"""Connect to NATS.""" """Connect to NATS."""
self._nc = await nats.connect(self._nats_url) 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 # on failure keep the last value and warn -- never block startup over a
# config read. # config read.
try: 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: except Exception as e:
logger.warning( 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)}, extra={"error": str(e)},
) )
area = self._monitoring_area
logger.info( logger.info(
"Publish-time monitoring area loaded", "Publish-time monitoring areas loaded",
extra={"monitoring_area": ( extra={
{"north": area.north, "south": area.south, "monitoring_areas": len(self._monitoring_areas),
"east": area.east, "west": area.west} if area else None "bounds": [
)}, {"north": a.north, "south": a.south,
"east": a.east, "west": a.west}
for a in self._monitoring_areas
],
},
) )
async def disconnect(self) -> None: async def disconnect(self) -> None:
@ -313,7 +328,7 @@ class Supervisor:
) )
except asyncio.TimeoutError: except asyncio.TimeoutError:
try: 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: except Exception as e:
logger.warning( logger.warning(
"Could not refresh monitoring area; keeping previous value", "Could not refresh monitoring area; keeping previous value",
@ -419,7 +434,7 @@ class Supervisor:
geom_json = build_geom_json( geom_json = build_geom_json(
event.geo.model_dump() if event.geo else None 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": if verdict == "out-of-bounds":
self._dropped_publish[state.name] = ( self._dropped_publish[state.name] = (
self._dropped_publish.get(state.name, 0) + 1 self._dropped_publish.get(state.name, 0) + 1

View file

@ -80,3 +80,40 @@ class TestProcessMessageFilter:
await c._process_message(msg, conn) await c._process_message(msg, conn)
conn.execute.assert_awaited_once() conn.execute.assert_awaited_once()
assert c._dropped == {} 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 == {}

View file

@ -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]

View file

@ -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

View file

@ -20,10 +20,14 @@ from central.monitoring_area import (
MonitoringArea, MonitoringArea,
build_geom_json, build_geom_json,
classify_geom, classify_geom,
classify_geom_areas,
load_monitoring_area, load_monitoring_area,
load_monitoring_areas,
) )
IDAHO = MonitoringArea(north=44.5, south=41.8, east=-111.0, west=-117.5) 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): def _pt(lon, lat):
@ -117,6 +121,55 @@ class TestClassifyGeom:
assert classify_geom(json.dumps({"type": "Nonsense"}), IDAHO) == "invalid-geom" 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 @pytest.mark.asyncio
class TestLoadMonitoringArea: class TestLoadMonitoringArea:
async def test_returns_area_when_all_columns_set(self): async def test_returns_area_when_all_columns_set(self):

View file

@ -71,6 +71,9 @@ def sup_factory():
store.set_adapter_last_error = AsyncMock() store.set_adapter_last_error = AsyncMock()
store.get_api_key = AsyncMock(return_value=None) store.get_api_key = AsyncMock(return_value=None)
store.get_monitoring_area = AsyncMock(return_value=area) 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 = MagicMock()
config_source.get_enrichment_config = AsyncMock(return_value=EnrichmentConfig()) config_source.get_enrichment_config = AsyncMock(return_value=EnrichmentConfig())
sup = sup_mod.Supervisor( sup = sup_mod.Supervisor(
@ -173,7 +176,7 @@ async def test_refresh_loop_reloads_area_and_logs_summary(
sup = sup_factory(None) sup = sup_factory(None)
sup._dropped_publish = {"mock": 7} sup._dropped_publish = {"mock": 7}
monkeypatch.setattr(sup_mod, "MONITORING_AREA_REFRESH_S", 0.05) 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): with caplog.at_level(logging.INFO):
task = asyncio.create_task(sup._refresh_monitoring_area_loop()) task = asyncio.create_task(sup._refresh_monitoring_area_loop())
await asyncio.sleep(0.15) await asyncio.sleep(0.15)