35f658b573
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.
- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
host's linked monitors + "add monitor for this host"; nav + widget registry
+ alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
http_monitors + all three result histories, drops the old tables/columns.
Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
208 lines
8.2 KiB
Python
208 lines
8.2 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
|
|
|
|
|
|
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 _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")
|
|
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="/monitors/",
|
|
))
|
|
return entries
|