Update to v 0.4.0 #6
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
+26
-1
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user