feat(status): unified Status page + widget across ping/DNS/HTTP monitors
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:
@@ -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
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user