mirror of
https://github.com/zvx-echo6/central.git
synced 2026-08-26 17:31:39 +00:00
v0.14.0: multi-bbox monitoring areas (config.monitoring_areas set-union filter)
Generalize the single system monitoring bbox into a named list of areas with set-union semantics: an event is kept by the archive (and at publish time) if its geometry intersects ANY configured area. No-geometry events are always kept; an empty list keeps everything (the pre-feature default). - Migration 042: new config.monitoring_areas table (name UNIQUE, north/south/ east/west, CHECK ordering); seeds 'default' from config.system.monitor_* so current bounds (N=49.0 S=41.8 E=-111.0 W=-117.5) are preserved. Old columns left in place for v0.14.0 (drop in v0.14.1). - monitoring_area.py: classify_geom_areas + load_monitoring_areas; single-area classify_geom / load_monitoring_area kept as back-compat shims. - archive + supervisor consumers load and apply the area list (set-union); _monitoring_area kept as a back-compat property over the internal list. - GUI /monitoring-area: list+create+update+delete (server-rendered forms), shared Leaflet map with per-name golden-angle colored rectangles, add/edit via leaflet.draw corner handles, empty-state banner, auto-fit to all areas. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3f1fec9846
commit
71e1e94fe1
13 changed files with 792 additions and 237 deletions
48
sql/migrations/042_monitoring_area_to_multi_areas.sql
Normal file
48
sql/migrations/042_monitoring_area_to_multi_areas.sql
Normal 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;
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
||||||
async def _read_monitoring_area(conn) -> dict[str, Any]:
|
|
||||||
"""Read the monitoring-area bbox + map tile settings from 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"
|
|
||||||
)
|
|
||||||
if row is None:
|
|
||||||
return {
|
|
||||||
**_DEFAULT_MONITOR,
|
|
||||||
"tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
"tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||||
"tile_attribution": "© OpenStreetMap contributors",
|
"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_tile_settings(conn) -> dict[str, str]:
|
||||||
async def monitoring_area_form(request: Request) -> HTMLResponse:
|
"""Map tile URL + attribution (shared GUI map settings on config.system)."""
|
||||||
"""Render the system monitoring-area editor (one draggable Leaflet rectangle).
|
row = await conn.fetchrow(
|
||||||
|
"SELECT map_tile_url, map_attribution FROM config.system WHERE id = true"
|
||||||
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"],
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
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.post("/monitoring-area")
|
async def _read_monitoring_areas(conn) -> list[dict[str, Any]]:
|
||||||
async def monitoring_area_update(request: Request) -> Response:
|
"""Read every configured monitoring area (v0.14.0 set-union bbox filter)."""
|
||||||
"""Validate + persist the monitoring-area bbox. The archive applies the new
|
rows = await conn.fetch(
|
||||||
bounds within ~60s via its background refresh (no restart needed)."""
|
"SELECT id, name, north, south, east, west "
|
||||||
templates = _get_templates()
|
"FROM config.monitoring_areas ORDER BY name"
|
||||||
pool = get_pool()
|
)
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
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] = {}
|
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 \
|
||||||
if not errors:
|
vals["north"] <= vals["south"]:
|
||||||
if vals["north"] <= vals["south"]:
|
|
||||||
errors["north"] = "North must be greater than South"
|
errors["north"] = "North must be greater than South"
|
||||||
if vals["east"] <= vals["west"]:
|
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"
|
errors["east"] = "East must be greater than West"
|
||||||
|
return vals, errors
|
||||||
|
|
||||||
if errors:
|
|
||||||
async with pool.acquire() as conn:
|
async def _render_monitoring_areas(
|
||||||
saved = await _read_monitoring_area(conn)
|
request, templates, conn, *, error=None, form_values=None,
|
||||||
render_area = {
|
edit_id=None, status=200,
|
||||||
"north": form.get("monitor_north") or saved["north"],
|
) -> HTMLResponse:
|
||||||
"south": form.get("monitor_south") or saved["south"],
|
"""Render the monitoring-areas page; reused by GET and the error paths."""
|
||||||
"east": form.get("monitor_east") or saved["east"],
|
areas = await _read_monitoring_areas(conn)
|
||||||
"west": form.get("monitor_west") or saved["west"],
|
tile = await _read_tile_settings(conn)
|
||||||
}
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
name="monitoring_area.html",
|
name="monitoring_area.html",
|
||||||
context={
|
context={
|
||||||
"operator": getattr(request.state, "operator", None),
|
"operator": getattr(request.state, "operator", None),
|
||||||
"csrf_token": request.state.csrf_token,
|
"csrf_token": request.state.csrf_token,
|
||||||
"area": render_area,
|
"areas": areas,
|
||||||
"tile_url": saved["tile_url"],
|
"tile_url": tile["tile_url"],
|
||||||
"tile_attribution": saved["tile_attribution"],
|
"tile_attribution": tile["tile_attribution"],
|
||||||
"errors": errors,
|
"default_bounds": _DEFAULT_MONITOR,
|
||||||
|
"error": error,
|
||||||
|
"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")
|
||||||
|
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(
|
await conn.execute(
|
||||||
"UPDATE config.system SET monitor_north=$1, monitor_south=$2, "
|
"INSERT INTO config.monitoring_areas (name, north, south, east, west) "
|
||||||
"monitor_east=$3, monitor_west=$4 WHERE id = true",
|
"VALUES ($1, $2, $3, $4, $5)",
|
||||||
vals["north"], vals["south"], vals["east"], vals["west"],
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 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 class="cols">
|
||||||
<div>
|
<div>
|
||||||
<label for="monitor_north">North</label>
|
<label for="ed-name">Name</label>
|
||||||
<input type="number" id="monitor_north" name="monitor_north" step="0.0001" min="-90" max="90" readonly value="{{ area.north }}">
|
<input type="text" id="ed-name" name="name" required maxlength="64" autocomplete="off">
|
||||||
{% if errors and errors.north %}<small class="field-error">{{ errors.north }}</small>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="monitor_south">South</label>
|
<label for="ed-north">North</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-north" name="north" step="0.0001" min="-90" max="90" readonly>
|
||||||
{% if errors and errors.south %}<small class="field-error">{{ errors.south }}</small>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="monitor_east">East</label>
|
<label for="ed-south">South</label>
|
||||||
<input type="number" id="monitor_east" name="monitor_east" step="0.0001" min="-180" max="180" readonly value="{{ area.east }}">
|
<input type="number" id="ed-south" name="south" step="0.0001" min="-90" max="90" readonly>
|
||||||
{% if errors and errors.east %}<small class="field-error">{{ errors.east }}</small>{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="monitor_west">West</label>
|
<label for="ed-east">East</label>
|
||||||
<input type="number" id="monitor_west" name="monitor_west" step="0.0001" min="-180" max="180" readonly value="{{ area.west }}">
|
<input type="number" id="ed-east" name="east" step="0.0001" min="-180" max="180" readonly>
|
||||||
{% if errors and errors.west %}<small class="field-error">{{ errors.west }}</small>{% endif %}
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p class="muted" style="margin-top:.5rem;">Drag the rectangle's corners on the map to set the bounds.</p>
|
||||||
<button type="button" id="region-reset-btn" class="btn-secondary" style="margin-top: 12px;">Reset to Saved</button>
|
<button type="submit" id="ed-save" class="btn-contrast">Create area</button>
|
||||||
</div>
|
<button type="button" id="ed-cancel" class="btn-secondary">Cancel</button>
|
||||||
|
|
||||||
<div style="margin-top: 1rem;">
|
|
||||||
<button type="submit" class="btn-primary">Save Monitoring Area</button>
|
|
||||||
</div>
|
|
||||||
</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 "{{ a.name }}"?');" 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 || '© 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 %}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 == {}
|
||||||
|
|
|
||||||
146
tests/test_gui_monitoring_area.py
Normal file
146
tests/test_gui_monitoring_area.py
Normal 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]
|
||||||
40
tests/test_migration_042.py
Normal file
40
tests/test_migration_042.py
Normal 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
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue