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():
+8
View File
@@ -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 = []
+233
View File
@@ -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
+10
View File
@@ -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",
+1
View File
@@ -0,0 +1 @@
# steward/status/ — unified monitor-status surface (page + dashboard widget)
+51
View File
@@ -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"),
)
+1
View File
@@ -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>
+12
View File
@@ -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 %}
+96
View File
@@ -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 %}
+27
View File
@@ -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 %}
+71
View File
@@ -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