From 3b6e005ed84830ab3535fab1a44be43b6e091408 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 15 Jun 2026 22:18:29 -0400 Subject: [PATCH] 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) --- plugins/http/__init__.py | 4 + plugins/http/routes.py | 87 +++++++++- steward/app.py | 8 + steward/core/status.py | 233 +++++++++++++++++++++++++++ steward/core/widgets.py | 10 ++ steward/status/__init__.py | 1 + steward/status/routes.py | 51 ++++++ steward/templates/base.html | 1 + steward/templates/status/index.html | 12 ++ steward/templates/status/rows.html | 96 +++++++++++ steward/templates/status/widget.html | 27 ++++ tests/core/test_status.py | 71 ++++++++ 12 files changed, 599 insertions(+), 2 deletions(-) create mode 100644 steward/core/status.py create mode 100644 steward/status/__init__.py create mode 100644 steward/status/routes.py create mode 100644 steward/templates/status/index.html create mode 100644 steward/templates/status/rows.html create mode 100644 steward/templates/status/widget.html create mode 100644 tests/core/test_status.py diff --git a/plugins/http/__init__.py b/plugins/http/__init__.py index cb79685..ef17129 100644 --- a/plugins/http/__init__.py +++ b/plugins/http/__init__.py @@ -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: diff --git a/plugins/http/routes.py b/plugins/http/routes.py index 24bede7..6f69d85 100644 --- a/plugins/http/routes.py +++ b/plugins/http/routes.py @@ -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(): diff --git a/steward/app.py b/steward/app.py index d1d3683..f488b87 100644 --- a/steward/app.py +++ b/steward/app.py @@ -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 = [] diff --git a/steward/core/status.py b/steward/core/status.py new file mode 100644 index 0000000..7c903be --- /dev/null +++ b/steward/core/status.py @@ -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'' + 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'' + f'' + f'' + ) + + +# ── 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 diff --git a/steward/core/widgets.py b/steward/core/widgets.py index e956a4b..ba01d7f 100644 --- a/steward/core/widgets.py +++ b/steward/core/widgets.py @@ -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", diff --git a/steward/status/__init__.py b/steward/status/__init__.py new file mode 100644 index 0000000..d0efc1c --- /dev/null +++ b/steward/status/__init__.py @@ -0,0 +1 @@ +# steward/status/ — unified monitor-status surface (page + dashboard widget) diff --git a/steward/status/routes.py b/steward/status/routes.py new file mode 100644 index 0000000..d4edcc9 --- /dev/null +++ b/steward/status/routes.py @@ -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"), + ) diff --git a/steward/templates/base.html b/steward/templates/base.html index 3f6837f..a23329d 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -198,6 +198,7 @@ textarea { resize: vertical; } Steward Dashboard + Status Hosts Uptime Ping diff --git a/steward/templates/status/index.html b/steward/templates/status/index.html new file mode 100644 index 0000000..8de5b21 --- /dev/null +++ b/steward/templates/status/index.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} +{% block title %}Status — Steward{% endblock %} +{% block content %} +
+

Status

+ Live — refreshes every {{ poll_interval }}s +
+ +
+
Loading status…
+
+{% endblock %} diff --git a/steward/templates/status/rows.html b/steward/templates/status/rows.html new file mode 100644 index 0000000..152e44e --- /dev/null +++ b/steward/templates/status/rows.html @@ -0,0 +1,96 @@ +{# status/rows.html — unified monitor list + summary, refreshed on poll #} +{% macro uptime_cell(pct) %} +{%- if pct is none -%} +{%- elif pct >= 99.9 -%}{{ "%.2f"|format(pct) }}% +{%- elif pct >= 99 -%}{{ "%.2f"|format(pct) }}% +{%- elif pct >= 95 -%}{{ "%.2f"|format(pct) }}% +{%- else -%}{{ "%.2f"|format(pct) }}% +{%- endif -%} +{% endmacro %} + +{# ── Summary ─────────────────────────────────────────────────────────────── #} +
+
+
Up
+ {{ up }} +
+
+
Down
+ {{ down }} +
+
+
Pending
+ {{ pending }} +
+
+ +{% if not entries %} +
+ No monitors configured yet. Add hosts (ping / DNS) or HTTP monitors to populate the board. +
+{% else %} +
+ {# header row #} +
+ Monitor + Recent checks + Latest + 24h + 7d + 30d + TLS +
+ + {% for e in entries %} +
+ {# name + kind #} +
+
+ + {% if e.detail_url %}{{ e.name }} + {% else %}{{ e.name }}{% endif %} +
+
+ {{ e.kind }} · {{ e.target }} +
+
+ + {# heartbeat bar #} +
+ {% set pad = 30 - e.heartbeat|length %} + {% if pad > 0 %}{% for _ in range(pad) %}{% endfor %}{% endif %} + {% for hb in e.heartbeat %} + + {% endfor %} +
+ + {# latest latency + sparkline #} +
+ {% if e.status == 'down' %}DOWN + {% elif e.latency_ms is not none %} + {% if e.spark_svg %}{{ e.spark_svg|safe }}
{% endif %} + {{ "%.0f"|format(e.latency_ms) }} ms + {% elif e.status == 'up' %}UP + {% else %}{% endif %} +
+ + {# uptime windows #} + {{ uptime_cell(e.uptime.get('24h')) }} + {{ uptime_cell(e.uptime.get('7d')) }} + {{ uptime_cell(e.uptime.get('30d')) }} + + {# TLS expiry countdown #} + + {% if e.tls_days is none %} + {% elif e.tls_days < 0 %}expired + {% elif e.tls_days < 14 %}{{ e.tls_days|round|int }}d + {% elif e.tls_days < 30 %}{{ e.tls_days|round|int }}d + {% else %}{{ e.tls_days|round|int }}d{% endif %} + +
+ {% endfor %} +
+

+ Heartbeat shows the last 30 checks (newest on the right). Uptime % is over the rolling window. TLS countdown applies to HTTPS monitors. +

+{% endif %} diff --git a/steward/templates/status/widget.html b/steward/templates/status/widget.html new file mode 100644 index 0000000..eef40b8 --- /dev/null +++ b/steward/templates/status/widget.html @@ -0,0 +1,27 @@ +{# status/widget.html — compact dashboard summary + problem list #} +
+ {{ up }}up + {{ down }}down + {{ pending }}pending +
+ +{% set problems = entries | rejectattr("status", "equalto", "up") | list %} +{% if not entries %} +
No monitors configured.
+{% elif not problems %} +
✓ All {{ up }} monitors are up.
+{% else %} +
+ {% for e in problems[:8] %} +
+ + {{ e.name }} + {{ e.kind }} + {{ e.status }} +
+ {% endfor %} + {% if problems|length > 8 %} +
+{{ problems|length - 8 }} more…
+ {% endif %} +
+{% endif %} diff --git a/tests/core/test_status.py b/tests/core/test_status.py new file mode 100644 index 0000000..5a51a5d --- /dev/null +++ b/tests/core/test_status.py @@ -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