From fda54a3c31f878ced83d337f02d653485bc667e4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 6 Oct 2026 14:40:45 -0400 Subject: [PATCH] =?UTF-8?q?feat(status):=20what=20is=20wrong=20first=20?= =?UTF-8?q?=E2=80=94=20down=20monitors=20and=20active=20alerts,=20and=20si?= =?UTF-8?q?nce=20when=20(#5120)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5120 "Status page: what is wrong first". - A "Needs attention" card heads the Status board: each down monitor with how long it has been down, and each active alert (firing, acknowledged, pending, no data) with its condition and how long, linking to its history. - StatusEntry.since / since_at_least: when the current up/down run began, from the recent checks (streak_start); "for at least" when every check we have agrees. - Counts as stat tiles (Down, Active alerts, Pending, Up); names wrap instead of being cut off; dots carry their label. - Uptime and TLS are coloured by status_class against Settings → Thresholds instead of literal cutoffs; heartbeat pills and DOWN/UP use tokens and status_badge rather than hex and bold. - tests/core/test_status_page.py: streak_start cases, and the rows render with the problem card before the list, each since-time, threshold tones and no hex colour. Co-Authored-By: Claude Opus 5.5 --- steward/core/status.py | 17 ++++++ steward/static/steward.css | 16 +++++ steward/status/routes.py | 8 ++- steward/templates/status/rows.html | 94 +++++++++++++++++------------- tests/core/test_status_page.py | 70 ++++++++++++++++++++++ 5 files changed, 162 insertions(+), 43 deletions(-) create mode 100644 tests/core/test_status_page.py diff --git a/steward/core/status.py b/steward/core/status.py index 435a806..5e69c5b 100644 --- a/steward/core/status.py +++ b/steward/core/status.py @@ -38,6 +38,10 @@ class StatusEntry: 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 + # When the current up/down state began, as far as the recent checks show; + # since_at_least means every check we have agrees, so it began earlier. + since: datetime | None = None + since_at_least: bool = False StatusSource = Callable[[object], Awaitable[list[StatusEntry]]] @@ -158,6 +162,17 @@ async def _uptime_by_monitor(db, monitor_ids: list[str]) -> dict[str, dict]: return out +def streak_start(results) -> tuple[datetime | None, bool]: + """(checked_at of the first check in the latest run of the same result, + whether that run fills every check given). Results are oldest-first.""" + if not results: + return None, False + i = len(results) - 1 + while i > 0 and results[i - 1].is_up == results[-1].is_up: + i -= 1 + return results[i].checked_at, i == 0 + + def _heartbeat_title(mtype: str, r) -> str: """Type-aware tooltip for one heartbeat cell.""" ts = f"{r.checked_at:%H:%M:%S} UTC" @@ -191,6 +206,7 @@ async def monitor_status_source(db) -> list[StatusEntry]: rows = recent.get(m.id, []) latest = rows[-1] if rows else None status = "pending" if latest is None else ("up" if latest.is_up else "down") + since, since_at_least = streak_start(rows) tls_days = None if latest and latest.tls_expires_at: tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0 @@ -203,5 +219,6 @@ async def monitor_status_source(db) -> list[StatusEntry]: 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="/monitors/", + since=since, since_at_least=since_at_least, )) return entries diff --git a/steward/static/steward.css b/steward/static/steward.css index daf9315..d753d8f 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -228,6 +228,13 @@ textarea { resize: vertical; } .dot-crit { background: var(--red); } .dot-info { background: var(--blue); } .dot-label { margin-left: 0.35rem; font-size: 0.85rem; } +/* Text in a tone: status_class() results ('ok' / 'warn' / 'crit' / 'unknown') + and the STATES tones. */ +.tone-ok { color: var(--green); } +.tone-warn { color: var(--yellow); } +.tone-crit { color: var(--red); } +.tone-info { color: var(--blue); } +.tone-dim, .tone-unknown { color: var(--text-dim); } .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } /* Empty state */ @@ -251,6 +258,13 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat .stat-crit .stat-tile-value { color: var(--red); } .stat-info .stat-tile-value { color: var(--blue); } .stat-dim .stat-tile-value { color: var(--text-dim); } +.stat-strip { display: grid; grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr)); gap: 0.75rem; margin-bottom: 1rem; } + +/* "Needs attention": what is wrong, first on a page. */ +.problem-card { border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); } +.problem-list { list-style: none; display: flex; flex-direction: column; gap: 0.45rem; } +.problem-list li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.6rem; overflow-wrap: anywhere; } +.problem-detail { color: var(--text-muted); font-size: 0.82rem; } /* Breadcrumbs */ /* Breadcrumb renders as a kicker line tucked directly above the page header @@ -307,6 +321,8 @@ body.dash-editing .widget-drawer { transform:translateY(0); } .pill { display:inline-block; width:7px; height:22px; border-radius:2px; cursor:default; transition:opacity .1s; } .pill:hover { opacity:.75; } .pill-empty { background:var(--bg-elevated); } +.pill-up { background:var(--green); } +.pill-down { background:var(--red); } .ping-cur { min-width:72px; text-align:right; font-size:0.85rem; font-variant-numeric:tabular-nums; } .ping-cur-good { color:var(--green); } .ping-cur-warn { color:var(--yellow); } diff --git a/steward/status/routes.py b/steward/status/routes.py index 202c33b..072d6e1 100644 --- a/steward/status/routes.py +++ b/steward/status/routes.py @@ -2,6 +2,7 @@ from __future__ import annotations from quart import Blueprint, current_app, render_template, request from sqlalchemy import func, select +from steward.alerts.service import active_alerts from steward.auth.middleware import require_role from steward.core.status import collect_status, sparkline_svg from steward.models.hosts import Host @@ -27,15 +28,18 @@ async def index(): @status_bp.get("/rows") @require_role(UserRole.viewer) async def rows(): - """HTMX fragment — the full status list + summary, refreshed on poll.""" + """HTMX fragment — what is wrong (down monitors, active alerts), then the + full status list, refreshed on poll.""" async with current_app.db_sessionmaker() as db: entries = await collect_status(db) + alerts = await active_alerts(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/rows.html", entries=entries, up=up, down=down, pending=pending, + alerts=alerts, problems=[e for e in entries if e.status == "down"], ) diff --git a/steward/templates/status/rows.html b/steward/templates/status/rows.html index 152e44e..86dc5a8 100644 --- a/steward/templates/status/rows.html +++ b/steward/templates/status/rows.html @@ -1,38 +1,52 @@ -{# 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 %} +{# status/rows.html — what is wrong first (down monitors, active alerts), then + every monitor; refreshed on poll #} +{% from "_macros.html" import status_badge, status_dot, stat_tile, empty_state %} +{% macro uptime_cell(pct) -%} +{%- if pct is none -%}— +{%- else -%}{{ "%.2f"|format(pct) }}%{%- endif -%} +{%- endmacro %} +{% macro since(e) -%} +{%- if e.since -%}{{ "for at least " if e.since_at_least else "since " }}{{ e.since|ago }}{%- endif -%} +{%- endmacro %} -{# ── Summary ─────────────────────────────────────────────────────────────── #} -
-
-
Up
- {{ up }} -
-
-
Down
- {{ down }} -
-
-
Pending
- {{ pending }} -
+{# ── What is wrong ───────────────────────────────────────────────────────── #} +{% if problems or alerts %} +
+

Needs attention

+
    + {% for e in problems %} +
  • + {{ status_badge('down') }} + {% if e.detail_url %}{{ e.name }}{% else %}{{ e.name }}{% endif %} + {{ e.kind|upper }} · {{ e.target }} · down {{ since(e) }} +
  • + {% endfor %} + {% for state, rule in alerts %} +
  • + {{ status_badge('no_data' if state.no_data_since and state.state.value == 'inactive' else state.state) }} + {{ rule.name }} + {{ rule.resource_name }} · {{ rule.metric_name }} {{ rule.operator.value }} {{ rule.threshold }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }} +
  • + {% endfor %} +
+
+{% endif %} + +{# ── Counts ──────────────────────────────────────────────────────────────── #} +
+ {{ stat_tile("Down", down, state="down" if down else "inactive") }} + {{ stat_tile("Active alerts", alerts|length, state="firing" if alerts else "inactive", href="/alerts/") }} + {{ stat_tile("Pending", pending, state="pending" if pending else "inactive") }} + {{ stat_tile("Up", up, state="up") }}
{% if not entries %} -
- No monitors configured yet. Add hosts (ping / DNS) or HTTP monitors to populate the board. -
+
{{ empty_state("No monitors yet. Add a host (ping or DNS) or an HTTP monitor to fill the board.", "Add a monitor", "/monitors/") }}
{% else %}
{# header row #}
- Monitor + Monitor Recent checks Latest 24h @@ -44,13 +58,13 @@ {% for e in entries %}
{# name + kind #} -
-
- - {% if e.detail_url %}{{ e.name }} - {% else %}{{ e.name }}{% endif %} +
+
+ {{ status_dot(e.status) }} + {% if e.detail_url %}{{ e.name }} + {% else %}{{ e.name }}{% endif %}
-
+
{{ e.kind }} · {{ e.target }}
@@ -60,17 +74,17 @@ {% 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 + {% if e.status == 'down' %}{{ status_badge('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 + {% elif e.status == 'up' %}{{ status_badge('up') }} {% else %}—{% endif %}
@@ -81,11 +95,9 @@ {# 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 %} + {% if e.tls_days is none %}— + {% elif e.tls_days < 0 %}expired + {% else %}{{ e.tls_days|round|int }}d{% endif %}
{% endfor %} diff --git a/tests/core/test_status_page.py b/tests/core/test_status_page.py new file mode 100644 index 0000000..5d8d4ff --- /dev/null +++ b/tests/core/test_status_page.py @@ -0,0 +1,70 @@ +"""The Status page says what is wrong, and since when, before anything else +(#5120).""" +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +import pytest +from quart import render_template + +from steward.core.status import StatusEntry, streak_start +from tests.test_style_tokens import hex_colours + +T0 = datetime(2026, 10, 6, 18, 0, tzinfo=timezone.utc) + + +def _checks(*ups): + return [SimpleNamespace(is_up=up, checked_at=T0 + timedelta(minutes=i)) for i, up in enumerate(ups)] + + +@pytest.mark.parametrize("ups, since, at_least", [ + ((), None, False), + ((True,), 0, True), + ((False, False, False), 0, True), # down for every check we have + ((True, True, False, False), 2, False), # went down at the third check + ((False, True, True, False), 3, False), +]) +def test_streak_start(ups, since, at_least): + checks = _checks(*ups) + assert streak_start(checks) == ((checks[since].checked_at if since is not None else None), at_least) + + +async def _rows(app, entries, alerts): + async with app.test_request_context("/status/rows"): + return await render_template( + "status/rows.html", entries=entries, alerts=alerts, + problems=[e for e in entries if e.status == "down"], + up=sum(e.status == "up" for e in entries), down=sum(e.status == "down" for e in entries), pending=0) + + +async def test_the_problems_come_first_and_say_since_when(app): + now = datetime.now(timezone.utc) + entries = [ + StatusEntry(kind="http", key="1", name="shop-api", target="https://shop/x", status="down", + since=now - timedelta(minutes=12), uptime={"24h": 93.0}, tls_days=2.0), + StatusEntry(kind="icmp", key="2", name="nas", target="10.0.0.5", status="up", + since=now - timedelta(days=3), since_at_least=True, uptime={"24h": 100.0}), + ] + state = SimpleNamespace(state=SimpleNamespace(value="firing"), no_data_since=None, + last_transition_at=now - timedelta(minutes=4)) + rule = SimpleNamespace(id="r1", name="CPU high", resource_name="web01", metric_name="cpu_pct", + operator=SimpleNamespace(value=">"), threshold=90) + page = await _rows(app, entries, [(state, rule)]) + + attention = page.index("Needs attention") + assert attention < page.index("Recent checks") # before the full list + problem_card = page[attention:page.index("")] + assert "shop-api" in problem_card and "nas" not in problem_card + assert "down since 12m ago" in problem_card + assert "CPU high" in problem_card and "cpu_pct > 90" in problem_card and ">4m ago" in problem_card + assert 'Firing' in problem_card + # Uptime and TLS coloured by Settings → Thresholds, not by literals. + assert '93.00%' in page and '100.00%' in page + assert 'class="tone-crit" title="TLS certificate expires in 2 days">2d' in page + assert not hex_colours(page), hex_colours(page) + + +async def test_a_quiet_board_has_no_problem_card(app): + entries = [StatusEntry(kind="icmp", key="2", name="nas", target="10.0.0.5", status="up")] + page = await _rows(app, entries, []) + assert "Needs attention" not in page and "nas" in page + assert "No monitors yet" in await _rows(app, [], [])