diff --git a/app/main.py b/app/main.py index 4db5847..74f94c7 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,25 @@ async def lifespan(app: FastAPI): await poller.start() logger.info("Background poller started") + # Auto-start keepalive live monitors for units configured for 24/7 monitoring + # (monitor_enabled). This is what keeps alerting running unattended across + # restarts — without it a feed only runs while someone has the live view open. + try: + from app.monitor import monitor_manager + from app.database import SessionLocal + from app.models import NL43Config + db = SessionLocal() + try: + units = db.query(NL43Config).filter_by(monitor_enabled=True, tcp_enabled=True).all() + for cfg in units: + m = await monitor_manager.get(cfg.unit_id) + await m.set_keepalive(True) + logger.info(f"Auto-started keepalive monitor for {cfg.unit_id}") + finally: + db.close() + except Exception as e: + logger.error(f"Failed to auto-start monitors: {e}") + yield # Application runs # Shutdown diff --git a/app/models.py b/app/models.py index 67e08ed..026aad6 100644 --- a/app/models.py +++ b/app/models.py @@ -23,6 +23,10 @@ class NL43Config(Base): poll_interval_seconds = Column(Integer, nullable=True, default=60) # Polling interval (10-3600 seconds) poll_enabled = Column(Boolean, default=True) # Enable/disable background polling for this device + # Live monitor (fan-out DOD feed). Keepalive runs it 24/7 even with no viewer, + # which is what makes alerting continuous. On by default; toggleable from the UI. + monitor_enabled = Column(Boolean, default=True) + class NL43Status(Base): """ diff --git a/app/routers.py b/app/routers.py index f317e15..80aeaaa 100644 --- a/app/routers.py +++ b/app/routers.py @@ -295,22 +295,32 @@ async def monitor_stream(websocket: WebSocket, unit_id: str): @router.post("/{unit_id}/monitor/start") -async def monitor_start(unit_id: str): - """Keep the device's feed running even with no browser attached, so alerting - evaluates continuously. Runtime-only (resets on restart).""" +async def monitor_start(unit_id: str, db: Session = Depends(get_db)): + """Enable 24/7 keepalive monitoring: persist monitor_enabled and start the feed + now, so alerting evaluates continuously even with no viewer. Survives restarts + (auto-started on boot from the persisted flag).""" + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if cfg: + cfg.monitor_enabled = True + db.commit() from app.monitor import monitor_manager monitor = await monitor_manager.get(unit_id) await monitor.set_keepalive(True) - return {"status": "ok", "unit_id": unit_id, "running": monitor.running, "keepalive": True} + return {"status": "ok", "unit_id": unit_id, "monitor_enabled": True, "running": monitor.running} @router.post("/{unit_id}/monitor/stop") -async def monitor_stop(unit_id: str): - """Drop the keep-alive; the feed stops once no browser subscribers remain.""" +async def monitor_stop(unit_id: str, db: Session = Depends(get_db)): + """Disable keepalive monitoring: persist monitor_enabled=False and drop the + keepalive (the feed stops once no browser subscribers remain).""" + cfg = db.query(NL43Config).filter_by(unit_id=unit_id).first() + if cfg: + cfg.monitor_enabled = False + db.commit() from app.monitor import monitor_manager monitor = await monitor_manager.get(unit_id) await monitor.set_keepalive(False) - return {"status": "ok", "unit_id": unit_id, "keepalive": False} + return {"status": "ok", "unit_id": unit_id, "monitor_enabled": False} @router.get("/_monitor/status") @@ -501,6 +511,7 @@ def get_roster(db: Session = Depends(get_db)): "web_enabled": cfg.web_enabled, "poll_enabled": cfg.poll_enabled, "poll_interval_seconds": cfg.poll_interval_seconds, + "monitor_enabled": cfg.monitor_enabled, "status": None } diff --git a/migrate_add_monitor_enabled.py b/migrate_add_monitor_enabled.py new file mode 100644 index 0000000..8853e61 --- /dev/null +++ b/migrate_add_monitor_enabled.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +""" +Migration: add monitor_enabled column to nl43_config. + +Controls whether the live fan-out DOD monitor is kept alive 24/7 for a unit +(which is what makes alerting continuous). Defaults to enabled. Run once per DB. +""" + +import sqlite3 +import sys +from pathlib import Path + +DB_PATH = Path(__file__).parent / "data" / "slmm.db" + + +def migrate(): + if not DB_PATH.exists(): + print(f"Database not found at {DB_PATH}") + print("No migration needed - database will be created with new schema") + return + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + try: + cursor.execute("PRAGMA table_info(nl43_config)") + columns = [row[1] for row in cursor.fetchall()] + + if "monitor_enabled" in columns: + print("✓ monitor_enabled column already exists, no migration needed") + return + + print("Adding monitor_enabled column (default enabled)...") + # SQLite stores booleans as 0/1; default 1 = enabled. + cursor.execute("ALTER TABLE nl43_config ADD COLUMN monitor_enabled BOOLEAN DEFAULT 1") + conn.commit() + print("✓ Added monitor_enabled column") + print("\n✓ Migration completed successfully!") + + except Exception as e: + conn.rollback() + print(f"✗ Migration failed: {e}", file=sys.stderr) + sys.exit(1) + finally: + conn.close() + + +if __name__ == "__main__": + migrate()