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():
|
||||
|
||||
@@ -98,6 +98,7 @@ def create_app(
|
||||
from .hosts.routes import hosts_bp
|
||||
from .ping.routes import ping_bp
|
||||
from .dns.routes import dns_bp
|
||||
from .status.routes import status_bp
|
||||
from .alerts.routes import alerts_bp
|
||||
from .ansible.routes import ansible_bp
|
||||
from .ansible.inventory_routes import inventory_bp
|
||||
@@ -109,12 +110,19 @@ def create_app(
|
||||
app.register_blueprint(hosts_bp)
|
||||
app.register_blueprint(ping_bp)
|
||||
app.register_blueprint(dns_bp)
|
||||
app.register_blueprint(status_bp)
|
||||
app.register_blueprint(alerts_bp)
|
||||
app.register_blueprint(ansible_bp)
|
||||
app.register_blueprint(inventory_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(audit_bp)
|
||||
|
||||
# Register the core (ping/DNS) status sources for the unified Status page.
|
||||
# Plugins register their own sources from setup() (e.g. http). Idempotent.
|
||||
from .core.status import register_status_source, ping_status_source, dns_status_source
|
||||
register_status_source(ping_status_source)
|
||||
register_status_source(dns_status_source)
|
||||
|
||||
# ── 8. Build task registry ─────────────────────────────────────────────────
|
||||
app._task_registry = []
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
# steward/core/status.py
|
||||
"""Unified monitor-status aggregation.
|
||||
|
||||
A single readable "is everything up?" surface (the Status page + dashboard
|
||||
widget) is assembled from heterogeneous monitor types — core ping/DNS, plus
|
||||
any plugin that contributes (e.g. the http plugin). Rather than have the core
|
||||
status page import plugin tables (plugins are optional and loaded late), each
|
||||
monitor type registers a *status source*: an async callable(db) that returns a
|
||||
list of normalised StatusEntry objects. Core registers its ping/DNS sources in
|
||||
create_app; a plugin registers its own from setup().
|
||||
"""
|
||||
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 # "ping" | "dns" | "http" | ...
|
||||
key: str # unique within kind (host_id / 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>'
|
||||
)
|
||||
|
||||
|
||||
# ── Shared query helpers (host-keyed result tables: ping, dns) ────────────────
|
||||
|
||||
async def _last_n_by_host(db, model, ts_col, host_ids: list[str],
|
||||
n: int = HEARTBEAT_COUNT) -> dict[str, list]:
|
||||
"""Return {host_id: [rows]} of the last n results per host, oldest-first."""
|
||||
if not host_ids:
|
||||
return {}
|
||||
rn = func.row_number().over(
|
||||
partition_by=model.host_id, order_by=ts_col.desc()
|
||||
).label("rn")
|
||||
subq = select(model.id, rn).where(model.host_id.in_(host_ids)).subquery()
|
||||
res = await db.execute(
|
||||
select(model)
|
||||
.join(subq, model.id == subq.c.id)
|
||||
.where(subq.c.rn <= n)
|
||||
.order_by(model.host_id, ts_col.asc())
|
||||
)
|
||||
out: dict[str, list] = {hid: [] for hid in host_ids}
|
||||
for row in res.scalars():
|
||||
out[row.host_id].append(row)
|
||||
return out
|
||||
|
||||
|
||||
async def _uptime_by_host(db, model, ts_col, status_col, up_value,
|
||||
host_ids: list[str]) -> dict[str, dict]:
|
||||
"""Per-host uptime % over 24h/7d/30d in one grouped query."""
|
||||
if not host_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(
|
||||
model.host_id,
|
||||
func.count().label("total_30d"),
|
||||
func.sum(case((status_col == up_value, 1), else_=0)).label("up_30d"),
|
||||
func.sum(case((ts_col >= c7, 1), else_=0)).label("total_7d"),
|
||||
func.sum(case((and_(ts_col >= c7, status_col == up_value), 1), else_=0)).label("up_7d"),
|
||||
func.sum(case((ts_col >= c24, 1), else_=0)).label("total_24h"),
|
||||
func.sum(case((and_(ts_col >= c24, status_col == up_value), 1), else_=0)).label("up_24h"),
|
||||
)
|
||||
.where(model.host_id.in_(host_ids))
|
||||
.where(ts_col >= c30)
|
||||
.group_by(model.host_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.host_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
|
||||
|
||||
|
||||
# ── Core status sources: ping + DNS ───────────────────────────────────────────
|
||||
|
||||
async def ping_status_source(db) -> list[StatusEntry]:
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.monitors import PingResult, PingStatus
|
||||
|
||||
hosts = (await db.execute(
|
||||
select(Host).where(Host.ping_enabled.is_(True)).order_by(Host.name)
|
||||
)).scalars().all()
|
||||
ids = [h.id for h in hosts]
|
||||
recent = await _last_n_by_host(db, PingResult, PingResult.probed_at, ids)
|
||||
uptime = await _uptime_by_host(
|
||||
db, PingResult, PingResult.probed_at, PingResult.status, PingStatus.up, ids
|
||||
)
|
||||
|
||||
entries: list[StatusEntry] = []
|
||||
for h in hosts:
|
||||
rows = recent.get(h.id, [])
|
||||
latest = rows[-1] if rows else None
|
||||
heartbeat = [{
|
||||
"state": "down" if r.status == PingStatus.down else "up",
|
||||
"title": (
|
||||
f"Down — {r.probed_at:%H:%M:%S} UTC" if r.status == PingStatus.down
|
||||
else f"{r.response_time_ms:.0f} ms — {r.probed_at:%H:%M:%S} UTC"
|
||||
if r.response_time_ms is not None else f"Up — {r.probed_at:%H:%M:%S} UTC"
|
||||
),
|
||||
} for r in rows]
|
||||
status = "pending" if latest is None else (
|
||||
"down" if latest.status == PingStatus.down else "up"
|
||||
)
|
||||
entries.append(StatusEntry(
|
||||
kind="ping", key=h.id, name=h.name, target=h.address, status=status,
|
||||
last_checked=latest.probed_at if latest else None,
|
||||
uptime=uptime.get(h.id, {}), heartbeat=heartbeat,
|
||||
latency_ms=latest.response_time_ms if latest else None,
|
||||
spark=[r.response_time_ms for r in rows if r.response_time_ms is not None],
|
||||
detail_url="/ping/",
|
||||
))
|
||||
return entries
|
||||
|
||||
|
||||
async def dns_status_source(db) -> list[StatusEntry]:
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.monitors import DnsResult, DnsStatus
|
||||
|
||||
hosts = (await db.execute(
|
||||
select(Host).where(Host.dns_enabled.is_(True)).order_by(Host.name)
|
||||
)).scalars().all()
|
||||
ids = [h.id for h in hosts]
|
||||
recent = await _last_n_by_host(db, DnsResult, DnsResult.resolved_at, ids)
|
||||
uptime = await _uptime_by_host(
|
||||
db, DnsResult, DnsResult.resolved_at, DnsResult.status, DnsStatus.resolved, ids
|
||||
)
|
||||
|
||||
entries: list[StatusEntry] = []
|
||||
for h in hosts:
|
||||
rows = recent.get(h.id, [])
|
||||
latest = rows[-1] if rows else None
|
||||
heartbeat = [{
|
||||
"state": "up" if r.status == DnsStatus.resolved else "down",
|
||||
"title": (
|
||||
f"{r.resolved_ip} — {r.resolved_at:%H:%M:%S} UTC" if r.status == DnsStatus.resolved
|
||||
else f"Failed — {r.resolved_at:%H:%M:%S} UTC"
|
||||
),
|
||||
} for r in rows]
|
||||
status = "pending" if latest is None else (
|
||||
"up" if latest.status == DnsStatus.resolved else "down"
|
||||
)
|
||||
entries.append(StatusEntry(
|
||||
kind="dns", key=h.id, name=h.name, target=h.address, status=status,
|
||||
last_checked=latest.resolved_at if latest else None,
|
||||
uptime=uptime.get(h.id, {}), heartbeat=heartbeat,
|
||||
detail_url="/dns/",
|
||||
))
|
||||
return entries
|
||||
@@ -22,6 +22,16 @@ WIDGET_REGISTRY: dict[str, dict] = {
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"status_overview": {
|
||||
"key": "status_overview",
|
||||
"label": "Status — Overview",
|
||||
"description": "Unified up/down/pending summary across ping, DNS, and HTTP monitors",
|
||||
"hx_url": "/status/widget",
|
||||
"detail_url": "/status",
|
||||
"plugin": None,
|
||||
"poll": True,
|
||||
"params": [],
|
||||
},
|
||||
"uptime_summary": {
|
||||
"key": "uptime_summary",
|
||||
"label": "Uptime / SLA",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# steward/status/ — unified monitor-status surface (page + dashboard widget)
|
||||
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
from quart import Blueprint, current_app, render_template, request
|
||||
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.core.status import collect_status, sparkline_svg
|
||||
from steward.models.users import UserRole
|
||||
|
||||
status_bp = Blueprint("status", __name__, url_prefix="/status")
|
||||
|
||||
|
||||
def _summarize(entries) -> tuple[int, int, int]:
|
||||
up = sum(1 for e in entries if e.status == "up")
|
||||
down = sum(1 for e in entries if e.status == "down")
|
||||
pending = sum(1 for e in entries if e.status == "pending")
|
||||
return up, down, pending
|
||||
|
||||
|
||||
@status_bp.get("/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def index():
|
||||
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
|
||||
return await render_template("status/index.html", poll_interval=poll_interval)
|
||||
|
||||
|
||||
@status_bp.get("/rows")
|
||||
@require_role(UserRole.viewer)
|
||||
async def rows():
|
||||
"""HTMX fragment — the full status list + summary, refreshed on poll."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entries = await collect_status(db)
|
||||
for e in entries:
|
||||
if e.spark:
|
||||
e.spark_svg = sparkline_svg(e.spark)
|
||||
up, down, pending = _summarize(entries)
|
||||
return await render_template(
|
||||
"status/rows.html", entries=entries, up=up, down=down, pending=pending
|
||||
)
|
||||
|
||||
|
||||
@status_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""HTMX dashboard widget — compact up/down/pending summary + problem list."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entries = await collect_status(db)
|
||||
up, down, pending = _summarize(entries)
|
||||
return await render_template(
|
||||
"status/widget.html",
|
||||
entries=entries, up=up, down=down, pending=pending,
|
||||
widget_id=request.args.get("wid", "0"),
|
||||
)
|
||||
@@ -198,6 +198,7 @@ textarea { resize: vertical; }
|
||||
Steward
|
||||
</a>
|
||||
<a href="/">Dashboard</a>
|
||||
<a href="/status">Status</a>
|
||||
<a href="/hosts/">Hosts</a>
|
||||
<a href="/hosts/uptime">Uptime</a>
|
||||
<a href="/ping/">Ping</a>
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Status — Steward{% endblock %}
|
||||
{% block content %}
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.25rem;gap:1rem;flex-wrap:wrap;">
|
||||
<h1 class="page-title" style="margin-bottom:0;">Status</h1>
|
||||
<span style="font-size:0.78rem;color:var(--text-dim);">Live — refreshes every {{ poll_interval }}s</span>
|
||||
</div>
|
||||
|
||||
<div hx-get="/status/rows" hx-trigger="load, every {{ poll_interval }}s" hx-swap="innerHTML">
|
||||
<div class="empty">Loading status…</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,96 @@
|
||||
{# status/rows.html — unified monitor list + summary, refreshed on poll #}
|
||||
{% macro uptime_cell(pct) %}
|
||||
{%- if pct is none -%}<span style="color:var(--text-dim);">—</span>
|
||||
{%- elif pct >= 99.9 -%}<span style="color:var(--green);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- elif pct >= 99 -%}<span style="color:var(--yellow);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- elif pct >= 95 -%}<span style="color:var(--orange);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- else -%}<span style="color:var(--red);">{{ "%.2f"|format(pct) }}%</span>
|
||||
{%- endif -%}
|
||||
{% endmacro %}
|
||||
|
||||
{# ── Summary ─────────────────────────────────────────────────────────────── #}
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:0.75rem;margin-bottom:1rem;">
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Up</div>
|
||||
<span class="stat-val" style="color:var(--green);">{{ up }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Down</div>
|
||||
<span class="stat-val" style="color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span>
|
||||
</div>
|
||||
<div class="card" style="margin-bottom:0;">
|
||||
<div class="section-title" style="margin-bottom:0.3rem;">Pending</div>
|
||||
<span class="stat-val" style="color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not entries %}
|
||||
<div class="card empty">
|
||||
No monitors configured yet. Add <a href="/hosts/">hosts</a> (ping / DNS) or HTTP monitors to populate the board.
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-flush">
|
||||
{# header row #}
|
||||
<div style="display:flex;align-items:center;gap:1rem;padding:0.55rem 1rem;border-bottom:1px solid var(--border-mid);font-size:0.72rem;color:var(--text-dim);text-transform:uppercase;letter-spacing:0.04em;">
|
||||
<span style="width:130px;flex-shrink:0;">Monitor</span>
|
||||
<span style="flex:1;min-width:0;">Recent checks</span>
|
||||
<span style="width:92px;text-align:right;flex-shrink:0;">Latest</span>
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;">24h</span>
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;">7d</span>
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;">30d</span>
|
||||
<span style="width:64px;text-align:right;flex-shrink:0;">TLS</span>
|
||||
</div>
|
||||
|
||||
{% for e in entries %}
|
||||
<div style="display:flex;align-items:center;gap:1rem;padding:0.55rem 1rem;border-bottom:1px solid var(--border);">
|
||||
{# name + kind #}
|
||||
<div style="width:130px;flex-shrink:0;overflow:hidden;">
|
||||
<div style="display:flex;align-items:center;gap:0.4rem;">
|
||||
<span class="dot {% if e.status == 'up' %}dot-up{% elif e.status == 'down' %}dot-down{% else %}dot-dim{% endif %}"></span>
|
||||
{% if e.detail_url %}<a href="{{ e.detail_url }}" style="font-size:0.85rem;font-weight:500;color:var(--text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</a>
|
||||
{% else %}<span style="font-size:0.85rem;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</span>{% endif %}
|
||||
</div>
|
||||
<div style="color:var(--text-dim);font-size:0.7rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-left:0.85rem;">
|
||||
<span style="text-transform:uppercase;letter-spacing:0.04em;">{{ e.kind }}</span> · {{ e.target }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# heartbeat bar #}
|
||||
<div style="flex:1;min-width:0;display:flex;gap:2px;align-items:center;overflow:hidden;">
|
||||
{% set pad = 30 - e.heartbeat|length %}
|
||||
{% if pad > 0 %}{% for _ in range(pad) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}{% endif %}
|
||||
{% for hb in e.heartbeat %}
|
||||
<span class="pill" style="background:{% if hb.state == 'down' %}#6a1515{% else %}#1a6632{% endif %};" title="{{ hb.title }}"></span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# latest latency + sparkline #}
|
||||
<div style="width:92px;text-align:right;flex-shrink:0;font-size:0.8rem;font-variant-numeric:tabular-nums;">
|
||||
{% if e.status == 'down' %}<span style="color:var(--red);font-weight:bold;">DOWN</span>
|
||||
{% elif e.latency_ms is not none %}
|
||||
{% if e.spark_svg %}<span style="opacity:0.8;">{{ e.spark_svg|safe }}</span><br>{% endif %}
|
||||
<span style="color:var(--text-muted);">{{ "%.0f"|format(e.latency_ms) }} ms</span>
|
||||
{% elif e.status == 'up' %}<span style="color:var(--green);">UP</span>
|
||||
{% else %}<span style="color:var(--text-dim);">—</span>{% endif %}
|
||||
</div>
|
||||
|
||||
{# uptime windows #}
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;font-size:0.8rem;font-family:ui-monospace,monospace;">{{ uptime_cell(e.uptime.get('24h')) }}</span>
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;font-size:0.8rem;font-family:ui-monospace,monospace;">{{ uptime_cell(e.uptime.get('7d')) }}</span>
|
||||
<span style="width:54px;text-align:center;flex-shrink:0;font-size:0.8rem;font-family:ui-monospace,monospace;">{{ uptime_cell(e.uptime.get('30d')) }}</span>
|
||||
|
||||
{# TLS expiry countdown #}
|
||||
<span style="width:64px;text-align:right;flex-shrink:0;font-size:0.78rem;font-variant-numeric:tabular-nums;">
|
||||
{% if e.tls_days is none %}<span style="color:var(--text-dim);">—</span>
|
||||
{% elif e.tls_days < 0 %}<span style="color:var(--red);font-weight:bold;" title="Certificate expired">expired</span>
|
||||
{% elif e.tls_days < 14 %}<span style="color:var(--red);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>
|
||||
{% elif e.tls_days < 30 %}<span style="color:var(--yellow);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>
|
||||
{% else %}<span style="color:var(--text-muted);" title="TLS certificate expiry">{{ e.tls_days|round|int }}d</span>{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p style="color:var(--text-dim);font-size:0.74rem;margin-top:0.75rem;">
|
||||
Heartbeat shows the last 30 checks (newest on the right). Uptime % is over the rolling window. TLS countdown applies to HTTPS monitors.
|
||||
</p>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,27 @@
|
||||
{# status/widget.html — compact dashboard summary + problem list #}
|
||||
<div style="display:flex;gap:1.25rem;align-items:baseline;margin-bottom:0.6rem;">
|
||||
<span><span class="stat-val" style="font-size:1.5rem;color:var(--green);">{{ up }}</span><span class="stat-label">up</span></span>
|
||||
<span><span class="stat-val" style="font-size:1.5rem;color:{% if down %}var(--red){% else %}var(--text-dim){% endif %};">{{ down }}</span><span class="stat-label">down</span></span>
|
||||
<span><span class="stat-val" style="font-size:1.5rem;color:{% if pending %}var(--yellow){% else %}var(--text-dim){% endif %};">{{ pending }}</span><span class="stat-label">pending</span></span>
|
||||
</div>
|
||||
|
||||
{% set problems = entries | rejectattr("status", "equalto", "up") | list %}
|
||||
{% if not entries %}
|
||||
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.3rem 0;">No monitors configured.</div>
|
||||
{% elif not problems %}
|
||||
<div style="color:var(--green);font-size:0.85rem;padding:0.3rem 0;">✓ All {{ up }} monitors are up.</div>
|
||||
{% else %}
|
||||
<div style="display:grid;gap:1px;">
|
||||
{% for e in problems[:8] %}
|
||||
<div style="display:flex;align-items:center;gap:0.5rem;padding:0.2rem 0;font-size:0.82rem;">
|
||||
<span class="dot {% if e.status == 'down' %}dot-down{% else %}dot-dim{% endif %}"></span>
|
||||
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ e.name }}</span>
|
||||
<span style="color:var(--text-dim);font-size:0.7rem;text-transform:uppercase;">{{ e.kind }}</span>
|
||||
<span style="color:{% if e.status == 'down' %}var(--red){% else %}var(--yellow){% endif %};font-size:0.75rem;font-weight:600;">{{ e.status }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if problems|length > 8 %}
|
||||
<div style="color:var(--text-dim);font-size:0.75rem;padding-top:0.2rem;">+{{ problems|length - 8 }} more…</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Unit tests for the status-source registry + sparkline (no DB, no Quart).
|
||||
|
||||
The DB-backed sources (ping/dns/http) are exercised in the integration lane;
|
||||
here we cover the registry contract, ordering, failure isolation, and the
|
||||
pure sparkline helper.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from steward.core import status as status_mod
|
||||
from steward.core.status import (
|
||||
StatusEntry,
|
||||
clear_status_sources,
|
||||
collect_status,
|
||||
register_status_source,
|
||||
sparkline_svg,
|
||||
)
|
||||
|
||||
|
||||
def _entry(name, st, kind="ping"):
|
||||
return StatusEntry(kind=kind, key=name, name=name, status=st)
|
||||
|
||||
|
||||
def test_collect_orders_down_then_pending_then_up_alphabetical():
|
||||
clear_status_sources()
|
||||
|
||||
async def src(db):
|
||||
return [_entry("zeta", "up"), _entry("alpha", "down"),
|
||||
_entry("beta", "pending"), _entry("gamma", "up")]
|
||||
|
||||
register_status_source(src)
|
||||
entries = asyncio.run(collect_status(None))
|
||||
assert [e.status for e in entries] == ["down", "pending", "up", "up"]
|
||||
# within the trailing "up" group, alphabetical by name
|
||||
assert entries[2].name == "gamma"
|
||||
assert entries[3].name == "zeta"
|
||||
clear_status_sources()
|
||||
|
||||
|
||||
def test_failing_source_is_skipped_not_fatal():
|
||||
clear_status_sources()
|
||||
|
||||
async def good(db):
|
||||
return [_entry("a", "up")]
|
||||
|
||||
async def bad(db):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
register_status_source(good)
|
||||
register_status_source(bad)
|
||||
entries = asyncio.run(collect_status(None))
|
||||
assert [e.name for e in entries] == ["a"]
|
||||
clear_status_sources()
|
||||
|
||||
|
||||
def test_register_is_idempotent_on_same_callable():
|
||||
clear_status_sources()
|
||||
|
||||
async def src(db):
|
||||
return []
|
||||
|
||||
register_status_source(src)
|
||||
register_status_source(src)
|
||||
assert status_mod._SOURCES.count(src) == 1
|
||||
clear_status_sources()
|
||||
|
||||
|
||||
def test_sparkline_svg_handles_edge_cases():
|
||||
assert "polyline" not in sparkline_svg([5]) # <2 points → empty svg
|
||||
assert "polyline" in sparkline_svg([1, 2, 3])
|
||||
assert "polyline" in sparkline_svg([4, 4, 4]) # flat series, no div-by-zero
|
||||
assert "polyline" in sparkline_svg([1, None, 3]) # None values filtered
|
||||
Reference in New Issue
Block a user