diff --git a/app/models.py b/app/models.py index 026aad6..c60ec6b 100644 --- a/app/models.py +++ b/app/models.py @@ -128,3 +128,24 @@ class AlertEvent(Base): acknowledged_at = Column(DateTime, nullable=True) acknowledged_by = Column(String, nullable=True) notes = Column(Text, nullable=True) + + +class NL43Reading(Base): + """Downsampled time-series of live-monitor readings, for the live-chart + backfill (so a viewer sees recent trend on open, not a blank chart). + + Viewing only — NOT the report source. Reports use the device's authoritative + FTP .rnd intervals. This is a short, capped trail (one row/minute, pruned to + a retention window) fed by the monitor's keepalive poll loop. + """ + + __tablename__ = "nl43_readings" + + id = Column(Integer, primary_key=True, autoincrement=True) + unit_id = Column(String, index=True, nullable=False) + timestamp = Column(DateTime, default=func.now(), index=True) + lp = Column(String, nullable=True) + leq = Column(String, nullable=True) + lmax = Column(String, nullable=True) + ln1 = Column(String, nullable=True) + ln2 = Column(String, nullable=True) diff --git a/app/monitor.py b/app/monitor.py index 5f7c8b3..a03dca5 100644 --- a/app/monitor.py +++ b/app/monitor.py @@ -36,6 +36,12 @@ MONITOR_POLL_INTERVAL = float(os.getenv("MONITOR_POLL_INTERVAL", "0.25")) # per-update latency (~2.5s -> ~1.3s). MONITOR_STATE_REFRESH_S = float(os.getenv("MONITOR_STATE_REFRESH_S", "30")) +# Downsampled trail for the live-chart backfill: store one reading per +# TRAIL_SAMPLE_S and keep TRAIL_RETENTION_HOURS of it (pruned). Viewing only — +# reports use the device's FTP .rnd data, not this. +TRAIL_SAMPLE_S = float(os.getenv("MONITOR_TRAIL_SAMPLE_S", "60")) +TRAIL_RETENTION_HOURS = float(os.getenv("MONITOR_TRAIL_RETENTION_HOURS", "24")) + # If nothing has been broadcast in this many seconds (e.g. device offline and # silent), send a keepalive frame so reverse proxies don't drop the idle WS. MONITOR_HEARTBEAT_S = float(os.getenv("MONITOR_HEARTBEAT_S", "25")) @@ -77,6 +83,7 @@ class DeviceMonitor: self._reachable = True # last broadcast reachability (for transition frames) self._cached_state: Optional[str] = None # run state, refreshed periodically self._last_state_refresh = 0.0 + self._last_trail_store = 0.0 # downsample throttle for the backfill trail @property def running(self) -> bool: @@ -190,6 +197,10 @@ class DeviceMonitor: snap.unit_id = self.unit_id persist_snapshot(snap, db) db.commit() + # Append to the downsampled backfill trail (~one row per TRAIL_SAMPLE_S). + if now - self._last_trail_store >= TRAIL_SAMPLE_S: + self._last_trail_store = now + self._store_trail(snap, db) status = db.query(NL43Status).filter_by(unit_id=self.unit_id).first() mst = (status.measurement_start_time.isoformat() if status and status.measurement_start_time else None) @@ -200,6 +211,24 @@ class DeviceMonitor: finally: db.close() + def _store_trail(self, snap, db) -> None: + """Append one downsampled reading to the backfill trail and prune old rows.""" + from datetime import datetime, timedelta + from app.models import NL43Reading + try: + db.add(NL43Reading( + unit_id=self.unit_id, timestamp=datetime.utcnow(), + lp=snap.lp, leq=snap.leq, lmax=snap.lmax, ln1=snap.ln1, ln2=snap.ln2, + )) + cutoff = datetime.utcnow() - timedelta(hours=TRAIL_RETENTION_HOURS) + db.query(NL43Reading).filter( + NL43Reading.unit_id == self.unit_id, + NL43Reading.timestamp < cutoff, + ).delete() + db.commit() + except Exception as e: + logger.warning(f"[MONITOR] {self.unit_id}: trail store failed: {e}") + def _broadcast(self, payload: dict, cache: bool = True) -> None: if cache: self._last_payload = payload # replayed to new subscribers diff --git a/app/routers.py b/app/routers.py index 80aeaaa..aae9a23 100644 --- a/app/routers.py +++ b/app/routers.py @@ -11,7 +11,7 @@ import os import asyncio from app.database import get_db -from app.models import NL43Config, NL43Status, AlertRule, AlertEvent +from app.models import NL43Config, NL43Status, AlertRule, AlertEvent, NL43Reading from app.services import NL43Client, persist_snapshot logger = logging.getLogger(__name__) @@ -330,6 +330,31 @@ async def monitor_status(): return {"status": "ok", "monitors": monitor_manager.status()} +@router.get("/{unit_id}/history") +def get_monitor_history(unit_id: str, hours: float = 2.0, db: Session = Depends(get_db)): + """Recent downsampled monitor readings (the DOD trail) for the live-chart + backfill. Viewing only — NOT the FTP report data.""" + from datetime import timedelta + hours = max(0.1, min(hours, 48.0)) + cutoff = datetime.utcnow() - timedelta(hours=hours) + rows = (db.query(NL43Reading) + .filter(NL43Reading.unit_id == unit_id, NL43Reading.timestamp >= cutoff) + .order_by(NL43Reading.timestamp.asc()).all()) + return { + "status": "ok", + "unit_id": unit_id, + "hours": hours, + "count": len(rows), + "readings": [ + { + "timestamp": r.timestamp.isoformat() if r.timestamp else None, + "lp": r.lp, "leq": r.leq, "lmax": r.lmax, "ln1": r.ln1, "ln2": r.ln2, + } + for r in rows + ], + } + + # ============================================================================ # ALERTS — threshold rules + fired events # ============================================================================