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,
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:

View file

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

View file

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

View file

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

View file

@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Central — Monitoring Area{% endblock %}
{% block title %}Central — Monitoring Areas{% endblock %}
{% block head %}
<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 %}
{% block content %}
<h1>Monitoring Area</h1>
<h1>Monitoring Areas</h1>
<p class="muted">
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
<strong>any</strong> 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.
</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 }}">
<div id="region-picker-container"
data-north="{{ area.north }}"
data-south="{{ area.south }}"
data-east="{{ area.east }}"
data-west="{{ area.west }}"
data-tile-url="{{ tile_url }}"
data-tile-attr="{{ tile_attribution }}">
<div id="region-map" style="height: 420px; margin-bottom: 1rem; border: 1px solid var(--rule); border-radius: var(--radius);"></div>
<div class="cols">
<div>
<label for="monitor_north">North</label>
<input type="number" id="monitor_north" name="monitor_north" step="0.0001" min="-90" max="90" readonly value="{{ area.north }}">
{% if errors and errors.north %}<small class="field-error">{{ errors.north }}</small>{% endif %}
</div>
<div>
<label for="monitor_south">South</label>
<input type="number" id="monitor_south" name="monitor_south" step="0.0001" min="-90" max="90" readonly value="{{ area.south }}">
{% 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 class="cols">
<div>
<label for="ed-name">Name</label>
<input type="text" id="ed-name" name="name" required maxlength="64" autocomplete="off">
</div>
<div>
<label for="ed-north">North</label>
<input type="number" id="ed-north" name="north" step="0.0001" min="-90" max="90" readonly>
</div>
<div>
<label for="ed-south">South</label>
<input type="number" id="ed-south" name="south" step="0.0001" min="-90" max="90" readonly>
</div>
<div>
<label for="ed-east">East</label>
<input type="number" id="ed-east" name="east" step="0.0001" min="-180" max="180" readonly>
</div>
<div>
<label for="ed-west">West</label>
<input type="number" id="ed-west" name="west" step="0.0001" min="-180" max="180" readonly>
</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>
<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>
{% 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>
(function() {
const container = document.getElementById('region-picker-container');
const savedNorth = parseFloat(container.dataset.north);
const savedSouth = parseFloat(container.dataset.south);
const savedEast = parseFloat(container.dataset.east);
const savedWest = parseFloat(container.dataset.west);
const tileUrl = container.dataset.tileUrl || 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const tileAttr = container.dataset.tileAttr || '&copy; OpenStreetMap contributors';
(function () {
const areas = JSON.parse(document.getElementById('areas-data').textContent || '[]');
const defaults = JSON.parse(document.getElementById('defaults-data').textContent);
const formValues = JSON.parse(document.getElementById('formvalues-data').textContent || 'null');
const editId = JSON.parse(document.getElementById('editid-data').textContent || 'null');
const tileUrl = {{ tile_url | tojson }};
const tileAttr = {{ tile_attribution | tojson }};
const centerLat = (savedNorth + savedSouth) / 2;
const centerLng = (savedEast + savedWest) / 2;
const map = L.map('region-map').setView([centerLat, centerLng], 5);
// Golden-angle hue per area name (same 137.508 step as events_list.html's
// per-NORAD coloring) so areas render as well-spread, repeatable colors.
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);
setTimeout(function() { map.invalidateSize(); }, 100);
setTimeout(function () { map.invalidateSize(); }, 100);
const bounds = L.latLngBounds(
L.latLng(savedSouth, savedWest),
L.latLng(savedNorth, savedEast)
);
map.fitBounds(bounds.pad(0.1));
let rectangle = L.rectangle(bounds, { color: '#3388ff', weight: 2, fillOpacity: 0.2 });
const drawnItems = new L.FeatureGroup();
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 }
// Draw every configured area as a colored, tooltipped rectangle.
const rects = {};
const group = L.featureGroup().addTo(map);
areas.forEach(function (a) {
const r = L.rectangle([[a.south, a.west], [a.north, a.east]],
{ color: colorForName(a.name), weight: 2, fillOpacity: 0.15 });
r.bindTooltip(a.name, { permanent: false });
r.addTo(group);
rects[a.id] = r;
});
map.addControl(drawControl);
rectangle.editing.enable();
const northInput = document.getElementById('monitor_north');
const southInput = document.getElementById('monitor_south');
const eastInput = document.getElementById('monitor_east');
const westInput = document.getElementById('monitor_west');
function updateInputs() {
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);
// Auto-fit to all areas + 10% padding; fall back to the default seed bounds.
if (areas.length) {
map.fitBounds(group.getBounds().pad(0.1));
} else {
map.setView([(defaults.north + defaults.south) / 2,
(defaults.east + defaults.west) / 2], 5);
}
rectangle.on('edit', updateInputs);
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.querySelectorAll('.area-swatch').forEach(function (s) {
s.style.background = colorForName(s.dataset.name);
});
document.getElementById('region-reset-btn').addEventListener('click', function() {
const originalBounds = L.latLngBounds(
L.latLng(savedSouth, savedWest),
L.latLng(savedNorth, savedEast)
);
drawnItems.clearLayers();
rectangle = L.rectangle(originalBounds, { color: '#3388ff', weight: 2, fillOpacity: 0.2 });
drawnItems.addLayer(rectangle);
rectangle.editing.enable();
rectangle.on('edit', updateInputs);
updateInputs();
const editor = document.getElementById('area-editor');
const fName = document.getElementById('ed-name');
const fN = document.getElementById('ed-north'), fS = document.getElementById('ed-south');
const fE = document.getElementById('ed-east'), fW = document.getElementById('ed-west');
const saveBtn = document.getElementById('ed-save');
let editRect = null;
function syncInputs() {
const b = editRect.getBounds();
fN.value = b.getNorth().toFixed(4); fS.value = b.getSouth().toFixed(4);
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>
{% endblock %}

View file

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

View file

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

View file

@ -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 == {}

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,
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):

View file

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