Files
FabledSteward/steward/core/status.py
T
bvandeusenandClaude Opus 5.5 e595bd922a
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 1m23s
CI / integration (push) Successful in 1m12s
CI / publish (push) Skipped
feat(monitors): problems first, a page per monitor, adding on its own page (#5121)
The Monitors list now opens with a count strip (down, up with how many
degraded, pending, paused) and sorts down, degraded, pending, up, paused,
by name within each. Degraded is up but worth a look: uptime at or below
its warning floor, or the latest response past its critical cutoff, both
from Settings → Thresholds; a slow-but-tolerable reply is coloured, not
sorted.

A monitor had no page of its own: every link went to the list. Now
/monitors/<id> shows its state and since when, latency, uptime for 24h/7d/
30d, TLS days, the last checks, recent failures with their errors, and its
settings (HTTP header names, never their values). Status links to it, and
adding or editing returns to it.

Adding is its own page (/monitors/new, form.html shared with edit) behind
an "Add monitor" button; a refused add re-renders the form with the error
and what was typed instead of a bare 400 text. Row actions are labelled
icon buttons, type names read Ping/TCP/DNS/HTTP (|monitor_type), and the
heartbeat uses pill-up/pill-down, so HEX_CEILING drops to 56.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 17:54:30 -04:00

225 lines
8.9 KiB
Python

# steward/core/status.py
"""Unified monitor-status aggregation.
A single readable "is everything up?" surface (the Status page + dashboard
widget) is assembled from heterogeneous monitors. The core unified Monitor
entity (ping/dns/http) contributes via `monitor_status_source`; plugins may
register their own sources from setup(). Each source is an async callable(db)
returning normalised StatusEntry objects so the Status page never imports
plugin tables directly.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from typing import Awaitable, Callable
from sqlalchemy import and_, case, func, select
logger = logging.getLogger(__name__)
HEARTBEAT_COUNT = 30 # number of recent checks shown in the heartbeat bar
@dataclass
class StatusEntry:
"""One monitored thing, normalised across monitor types for display."""
kind: str # "icmp" | "tcp" | "dns" | "http" | ...
key: str # unique within kind (monitor id)
name: str
target: str = "" # address / URL shown as subtitle
status: str = "pending" # "up" | "down" | "pending"
last_checked: datetime | None = None
uptime: dict[str, float | None] = field(default_factory=dict) # 24h/7d/30d
heartbeat: list[dict] = field(default_factory=list) # oldest-first {state,title}
latency_ms: float | None = None
spark: list[float] = field(default_factory=list) # response series
spark_svg: str = "" # filled by the route via sparkline_svg()
tls_days: float | None = None # days until TLS expiry (http only)
detail_url: str | None = None
# When the current up/down state began, as far as the recent checks show;
# since_at_least means every check we have agrees, so it began earlier.
since: datetime | None = None
since_at_least: bool = False
StatusSource = Callable[[object], Awaitable[list[StatusEntry]]]
_SOURCES: list[StatusSource] = []
def register_status_source(fn: StatusSource) -> None:
"""Register a status source. Idempotent on the same callable."""
if fn not in _SOURCES:
_SOURCES.append(fn)
def clear_status_sources() -> None:
"""Reset the registry (used by tests)."""
_SOURCES.clear()
async def collect_status(db) -> list[StatusEntry]:
"""Gather entries from every registered source, down-first then by name.
A failing source is logged and skipped so one broken plugin can't blank
the whole Status page.
"""
entries: list[StatusEntry] = []
for src in list(_SOURCES):
try:
entries.extend(await src(db))
except Exception:
logger.exception("status source %r failed", getattr(src, "__name__", src))
# Down first (most urgent), then pending, then up; alphabetical within.
order = {"down": 0, "pending": 1, "up": 2}
entries.sort(key=lambda e: (order.get(e.status, 3), e.kind, e.name.lower()))
return entries
def sparkline_svg(values: list[float], width: int = 80, height: int = 20,
stroke: str = "#6060c0") -> str:
"""Tiny inline SVG polyline for a response-time series."""
vals = [v for v in values if v is not None]
if len(vals) < 2:
return f'<svg width="{width}" height="{height}"></svg>'
mn, mx = min(vals), max(vals)
if mx == mn:
mx = mn + 1.0
step = width / (len(vals) - 1)
pts = []
for i, v in enumerate(vals):
x = i * step
y = height - (v - mn) / (mx - mn) * (height - 2) - 1
pts.append(f"{x:.1f},{y:.1f}")
poly = " ".join(pts)
return (
f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" '
f'style="vertical-align:middle;">'
f'<polyline points="{poly}" fill="none" stroke="{stroke}" stroke-width="1.5"/>'
f'</svg>'
)
# ── Unified monitor status source ─────────────────────────────────────────────
async def _last_n_results(db, monitor_ids: list[str],
n: int = HEARTBEAT_COUNT) -> dict[str, list]:
"""Return {monitor_id: [MonitorResult]} of the last n results, oldest-first."""
from steward.models.monitors import MonitorResult
if not monitor_ids:
return {}
rn = func.row_number().over(
partition_by=MonitorResult.monitor_id, order_by=MonitorResult.checked_at.desc()
).label("rn")
subq = select(MonitorResult.id, rn).where(
MonitorResult.monitor_id.in_(monitor_ids)
).subquery()
res = await db.execute(
select(MonitorResult)
.join(subq, MonitorResult.id == subq.c.id)
.where(subq.c.rn <= n)
.order_by(MonitorResult.monitor_id, MonitorResult.checked_at.asc())
)
out: dict[str, list] = {mid: [] for mid in monitor_ids}
for row in res.scalars():
out[row.monitor_id].append(row)
return out
async def _uptime_by_monitor(db, monitor_ids: list[str]) -> dict[str, dict]:
"""Per-monitor uptime % over 24h/7d/30d in one grouped query."""
from steward.models.monitors import MonitorResult
if not monitor_ids:
return {}
now = datetime.now(timezone.utc)
c30, c7, c24 = now - timedelta(days=30), now - timedelta(days=7), now - timedelta(hours=24)
res = await db.execute(
select(
MonitorResult.monitor_id,
func.count().label("total_30d"),
func.sum(case((MonitorResult.is_up.is_(True), 1), else_=0)).label("up_30d"),
func.sum(case((MonitorResult.checked_at >= c7, 1), else_=0)).label("total_7d"),
func.sum(case((and_(MonitorResult.checked_at >= c7, MonitorResult.is_up.is_(True)), 1), else_=0)).label("up_7d"),
func.sum(case((MonitorResult.checked_at >= c24, 1), else_=0)).label("total_24h"),
func.sum(case((and_(MonitorResult.checked_at >= c24, MonitorResult.is_up.is_(True)), 1), else_=0)).label("up_24h"),
)
.where(MonitorResult.monitor_id.in_(monitor_ids))
.where(MonitorResult.checked_at >= c30)
.group_by(MonitorResult.monitor_id)
)
def _pct(up, total):
return round(float(up) / float(total) * 100, 2) if total else None
out: dict[str, dict] = {}
for r in res:
out[r.monitor_id] = {
"24h": _pct(r.up_24h, r.total_24h),
"7d": _pct(r.up_7d, r.total_7d),
"30d": _pct(r.up_30d, r.total_30d),
}
return out
def streak_start(results) -> tuple[datetime | None, bool]:
"""(checked_at of the first check in the latest run of the same result,
whether that run fills every check given). Results are oldest-first."""
if not results:
return None, False
i = len(results) - 1
while i > 0 and results[i - 1].is_up == results[-1].is_up:
i -= 1
return results[i].checked_at, i == 0
def _heartbeat_title(mtype: str, r) -> str:
"""Type-aware tooltip for one heartbeat cell."""
ts = f"{r.checked_at:%H:%M:%S} UTC"
if not r.is_up:
return f"{(r.error_msg or 'Down')} — {ts}"
if mtype == "http":
code = r.status_code or "—"
ms = f"{r.response_ms:.0f} ms" if r.response_ms is not None else ""
return f"{code} · {ms} — {ts}".replace(" · — ", " — ")
if mtype == "dns":
return f"{r.resolved_ip or 'resolved'} — {ts}"
if r.response_ms is not None:
return f"{r.response_ms:.0f} ms — {ts}"
return f"Up — {ts}"
async def monitor_status_source(db) -> list[StatusEntry]:
"""Contribute every enabled Monitor (all types) to the Status page."""
from steward.models.monitors import Monitor
monitors = list((await db.execute(
select(Monitor).where(Monitor.enabled.is_(True)).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)
now = datetime.now(timezone.utc)
entries: list[StatusEntry] = []
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
status = "pending" if latest is None else ("up" if latest.is_up else "down")
since, since_at_least = streak_start(rows)
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
entries.append(StatusEntry(
kind=m.type, key=m.id, name=m.name, target=m.target, status=status,
last_checked=latest.checked_at if latest else None,
uptime=uptime.get(m.id, {}),
heartbeat=[{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
latency_ms=latest.response_ms if latest else None,
spark=[r.response_ms for r in rows if r.response_ms is not None],
tls_days=tls_days, detail_url=f"/monitors/{m.id}",
since=since, since_at_least=since_at_least,
))
return entries