feat(status): unified Status page + widget across ping/DNS/HTTP monitors
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 7s
CI / integration (push) Successful in 2m15s
CI / publish (push) Successful in 56s

Adds a Kuma-style "is everything up?" surface that aggregates heterogeneous
monitor types via a status-source registry (steward/core/status.py): each
type registers an async source(db) -> [StatusEntry]. Core registers ping/DNS;
the http plugin registers its own from setup() so core never imports plugin
tables. Per entry: current up/down, last-30 heartbeat bar, uptime %
(24h/7d/30d), latest latency + response sparkline, and TLS expiry countdown
(HTTP). New /status page (live htmx refresh) + a status_overview dashboard
widget + nav link. Pure-function unit tests for registry + sparkline.

First deliverable of milestone #68 (task #866).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 22:18:29 -04:00
parent 67a1bc740d
commit 3b6e005ed8
12 changed files with 599 additions and 2 deletions
+4
View File
@@ -12,6 +12,10 @@ def setup(app: "Quart") -> None:
global _app
_app = app
from .models import HttpMonitor, HttpResult # noqa: F401
# Contribute HTTP monitors to the core unified Status page.
from steward.core.status import register_status_source
from .routes import http_status_source
register_status_source(http_status_source)
def get_scheduled_tasks() -> list:
+85 -2
View File
@@ -1,12 +1,13 @@
# plugins/http/routes.py
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from datetime import datetime, timedelta, timezone
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from sqlalchemy import Integer, cast, func, select
from sqlalchemy import Integer, and_, case, cast, func, select
from steward.auth.middleware import require_role
from steward.models.users import UserRole
from steward.core.status import StatusEntry, HEARTBEAT_COUNT
from steward.core.time_range import parse_range, DEFAULT_RANGE, bucket_seconds
from .models import HttpMonitor, HttpResult
@@ -94,6 +95,88 @@ async def _render_rows(range_key: str = DEFAULT_RANGE):
return monitor_data, up, down, range_key
async def http_status_source(db) -> list[StatusEntry]:
"""Contribute enabled HTTP monitors to the unified Status page.
Registered with steward.core.status from this plugin's setup() so the core
Status surface can include HTTP without importing plugin tables directly.
"""
monitors = list((await db.execute(
select(HttpMonitor).where(HttpMonitor.enabled == True) # noqa: E712
.order_by(HttpMonitor.created_at)
)).scalars())
ids = [m.id for m in monitors]
if not ids:
return []
now = datetime.now(timezone.utc)
c30, c7, c24 = now - timedelta(days=30), now - timedelta(days=7), now - timedelta(hours=24)
up_res = await db.execute(
select(
HttpResult.monitor_id,
func.count().label("total_30d"),
func.sum(case((HttpResult.is_up == True, 1), else_=0)).label("up_30d"), # noqa: E712
func.sum(case((HttpResult.checked_at >= c7, 1), else_=0)).label("total_7d"),
func.sum(case((and_(HttpResult.checked_at >= c7, HttpResult.is_up == True), 1), else_=0)).label("up_7d"), # noqa: E712
func.sum(case((HttpResult.checked_at >= c24, 1), else_=0)).label("total_24h"),
func.sum(case((and_(HttpResult.checked_at >= c24, HttpResult.is_up == True), 1), else_=0)).label("up_24h"), # noqa: E712
)
.where(HttpResult.monitor_id.in_(ids))
.where(HttpResult.checked_at >= c30)
.group_by(HttpResult.monitor_id)
)
def _pct(u, t):
return round(float(u) / float(t) * 100, 2) if t else None
uptime = {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),
} for r in up_res}
# Last N results per monitor (one query) for the heartbeat bar + sparkline.
rn = func.row_number().over(
partition_by=HttpResult.monitor_id, order_by=HttpResult.checked_at.desc()
).label("rn")
subq = select(HttpResult.id, rn).where(HttpResult.monitor_id.in_(ids)).subquery()
recent_res = await db.execute(
select(HttpResult)
.join(subq, HttpResult.id == subq.c.id)
.where(subq.c.rn <= HEARTBEAT_COUNT)
.order_by(HttpResult.monitor_id, HttpResult.checked_at.asc())
)
recent: dict[str, list] = {mid: [] for mid in ids}
for row in recent_res.scalars():
recent[row.monitor_id].append(row)
entries: list[StatusEntry] = []
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
heartbeat = [{
"state": "up" if r.is_up else "down",
"title": (
f"{r.status_code or ''} · {r.response_ms:.0f} ms — {r.checked_at:%H:%M:%S} UTC"
if r.is_up and r.response_ms is not None
else f"{'Up' if r.is_up else 'Down'}{r.checked_at:%H:%M:%S} UTC"
),
} for r in rows]
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="http", key=m.id, name=m.name, target=m.url, status=status,
last_checked=latest.checked_at if latest else None,
uptime=uptime.get(m.id, {}), heartbeat=heartbeat,
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="/plugins/http/",
))
return entries
@http_bp.get("/")
@require_role(UserRole.viewer)
async def index():