"""Monitor queries shared by the monitors pages and the MCP tools. Each function takes an open session, with no Quart request context, so the routes and the /mcp tools (which run outside Quart's pipeline) call the same code. """ from __future__ import annotations from datetime import datetime, timezone from sqlalchemy import func, select from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor from steward.core.time_range import parse_range from steward.models.monitors import Monitor, MonitorResult async def monitor_rows(db, range_key: str): """Per-monitor state: latest result, heartbeat strip, uptime and TLS days. Returns (rows, up, down, pending, range_key). `range_key` picks the uptime window surfaced (24h/7d/30d; anything else falls back to 24h) and comes back normalised. """ _since, range_key = parse_range(range_key) monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars()) ids = [m.id for m in monitors] recent = await _last_n_results(db, ids) uptime = await _uptime_by_monitor(db, ids) # Map the selected range to the uptime bucket we surface in the list. bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h") now = datetime.now(timezone.utc) data = [] for m in monitors: rows = recent.get(m.id, []) latest = rows[-1] if rows else None tls_days = None if latest and latest.tls_expires_at: tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 data.append({ "monitor": m, "latest": latest, "heartbeat": [{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)} for r in rows], "uptime_pct": uptime.get(m.id, {}).get(bucket), "tls_days": tls_days, }) up = sum(1 for d in data if d["latest"] and d["latest"].is_up) down = sum(1 for d in data if d["latest"] and not d["latest"].is_up) pending = sum(1 for d in data if not d["latest"]) return data, up, down, pending, range_key async def down_since(db, monitor_id: str): """When the monitor's current run of failures began, or None if its latest check is up. The first failed check after the last success; if it has never succeeded, its first check. """ last_up = (await db.execute( select(func.max(MonitorResult.checked_at)) .where(MonitorResult.monitor_id == monitor_id, MonitorResult.is_up.is_(True)) )).scalar() stmt = select(func.min(MonitorResult.checked_at)).where(MonitorResult.monitor_id == monitor_id) if last_up is not None: stmt = stmt.where(MonitorResult.checked_at > last_up) return (await db.execute(stmt)).scalar() async def monitor_states(db, monitors: list[Monitor]) -> dict[str, dict]: """{monitor_id: {latest, recent, uptime, down_since}} for the given monitors. `recent` is the last HEARTBEAT_COUNT results oldest-first; `uptime` is {"24h","7d","30d"} percentages; `down_since` is set only for a monitor whose latest check failed. """ ids = [m.id for m in monitors] recent = await _last_n_results(db, ids) uptime = await _uptime_by_monitor(db, ids) out: dict[str, dict] = {} for m in monitors: rows = recent.get(m.id, []) latest = rows[-1] if rows else None out[m.id] = { "latest": latest, "recent": rows, "uptime": uptime.get(m.id, {}), "down_since": None, } if latest is not None and not latest.is_up: out[m.id]["down_since"] = await down_since(db, m.id) return out async def recent_failures(db, monitor_id: str, limit: int = 10) -> list[MonitorResult]: """The monitor's latest failed checks, newest first.""" return list((await db.execute( select(MonitorResult) .where(MonitorResult.monitor_id == monitor_id, MonitorResult.is_up.is_(False)) .order_by(MonitorResult.checked_at.desc()) .limit(limit) )).scalars())