Update to v 0.4.0 #6

Merged
serversdown merged 21 commits from dev into main 2026-06-22 18:07:37 -04:00
3 changed files with 76 additions and 1 deletions
Showing only changes of commit d1d694302c - Show all commits
+21
View File
@@ -128,3 +128,24 @@ class AlertEvent(Base):
acknowledged_at = Column(DateTime, nullable=True) acknowledged_at = Column(DateTime, nullable=True)
acknowledged_by = Column(String, nullable=True) acknowledged_by = Column(String, nullable=True)
notes = Column(Text, 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)
+29
View File
@@ -36,6 +36,12 @@ MONITOR_POLL_INTERVAL = float(os.getenv("MONITOR_POLL_INTERVAL", "0.25"))
# per-update latency (~2.5s -> ~1.3s). # per-update latency (~2.5s -> ~1.3s).
MONITOR_STATE_REFRESH_S = float(os.getenv("MONITOR_STATE_REFRESH_S", "30")) 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 # 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. # silent), send a keepalive frame so reverse proxies don't drop the idle WS.
MONITOR_HEARTBEAT_S = float(os.getenv("MONITOR_HEARTBEAT_S", "25")) 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._reachable = True # last broadcast reachability (for transition frames)
self._cached_state: Optional[str] = None # run state, refreshed periodically self._cached_state: Optional[str] = None # run state, refreshed periodically
self._last_state_refresh = 0.0 self._last_state_refresh = 0.0
self._last_trail_store = 0.0 # downsample throttle for the backfill trail
@property @property
def running(self) -> bool: def running(self) -> bool:
@@ -190,6 +197,10 @@ class DeviceMonitor:
snap.unit_id = self.unit_id snap.unit_id = self.unit_id
persist_snapshot(snap, db) persist_snapshot(snap, db)
db.commit() 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() status = db.query(NL43Status).filter_by(unit_id=self.unit_id).first()
mst = (status.measurement_start_time.isoformat() mst = (status.measurement_start_time.isoformat()
if status and status.measurement_start_time else None) if status and status.measurement_start_time else None)
@@ -200,6 +211,24 @@ class DeviceMonitor:
finally: finally:
db.close() 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: def _broadcast(self, payload: dict, cache: bool = True) -> None:
if cache: if cache:
self._last_payload = payload # replayed to new subscribers self._last_payload = payload # replayed to new subscribers
+26 -1
View File
@@ -11,7 +11,7 @@ import os
import asyncio import asyncio
from app.database import get_db 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 from app.services import NL43Client, persist_snapshot
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -330,6 +330,31 @@ async def monitor_status():
return {"status": "ok", "monitors": monitor_manager.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 # ALERTS — threshold rules + fired events
# ============================================================================ # ============================================================================