feat(monitors): Monitors absorbs the Status page; Status is removed (#5270)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m28s

Status repeated the Monitors page's counts, check strip and uptime. Monitors
now has one header row and every column Status had: the last 30 checks,
latest response with its trend, uptime over 24h / 7d / 30d (the range picker
goes) and TLS days on every row. /status and /status/rows and the nav link
are removed outright; the dashboard Overview (/status/widget) stays and its
Details link goes to Monitors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 15:36:38 -04:00
co-authored by Claude Opus 5.5
parent eaf9f1bcb8
commit 434127b3e1
16 changed files with 72 additions and 256 deletions
-1
View File
@@ -35,7 +35,6 @@ class StatusEntry:
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
# When the current up/down state began, as far as the recent checks show;
+1 -1
View File
@@ -65,7 +65,7 @@ WIDGET_REGISTRY: dict[str, dict] = {
"label": "Overview",
"description": "What needs attention across Steward: hosts, monitors and firing alerts, problems listed with since-times",
"hx_url": "/status/widget",
"detail_url": "/status/",
"detail_url": "/monitors/",
"plugin": None,
"poll": True,
"params": [],
+4 -14
View File
@@ -16,7 +16,6 @@ from steward.core.audit import audit_request
from steward.models.users import UserRole
from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES
from steward.core.status import _heartbeat_title, streak_start
from steward.core.time_range import DEFAULT_RANGE
from steward.models.hosts import Host
from steward.monitors import service as monitor_svc
@@ -47,23 +46,15 @@ def _form_opts(form, mtype: str) -> dict:
@require_role(UserRole.viewer)
async def index():
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template(
"monitors/index.html",
poll_interval=poll_interval,
current_range=request.args.get("range", DEFAULT_RANGE),
)
return await render_template("monitors/index.html", poll_interval=poll_interval)
@monitors_bp.get("/rows")
@require_role(UserRole.viewer)
async def rows():
async with current_app.db_sessionmaker() as db:
data, counts, range_key = await monitor_svc.monitor_rows(
db, request.args.get("range", DEFAULT_RANGE), current_app.config.get("THRESHOLDS", {}))
return await render_template(
"monitors/rows.html",
monitor_data=data, counts=counts, range_key=range_key,
)
data, counts = await monitor_svc.monitor_rows(db, current_app.config.get("THRESHOLDS", {}))
return await render_template("monitors/rows.html", monitor_data=data, counts=counts)
@monitors_bp.post("/add")
@@ -219,8 +210,7 @@ async def widget():
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
data, counts, _ = await monitor_svc.monitor_rows(
db, DEFAULT_RANGE, current_app.config.get("THRESHOLDS", {}))
data, counts = await monitor_svc.monitor_rows(db, current_app.config.get("THRESHOLDS", {}))
if type_filter in MONITOR_TYPE_VALUES:
data = [d for d in data if d["monitor"].type == type_filter]
# Counted after the type filter, so the verdict speaks for what is listed.
+12 -16
View File
@@ -14,7 +14,6 @@ from sqlalchemy import func, select
from steward.core.formatting import status_class
from steward.core.status import _heartbeat_title, _last_n_results, _uptime_by_monitor
from steward.core.time_range import parse_range
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor, MonitorResult, MonitorType
HTTP_METHODS = ("GET", "HEAD", "POST")
@@ -171,40 +170,37 @@ def problems_first(rows: list[dict]) -> list[dict]:
return sorted(rows, key=lambda d: (STATE_ORDER[d["state"]], d["monitor"].name.lower()))
async def monitor_rows(db, range_key: str, thresholds: dict):
"""Per-monitor state: latest result, heartbeat strip, uptime and TLS days,
problems first.
async def monitor_rows(db, thresholds: dict):
"""Per-monitor state: latest result, heartbeat strip, latency trend,
uptime over 24h / 7d / 30d and TLS days, problems first.
Returns (rows, counts, range_key). `counts` has up (degraded included),
degraded, down, pending and paused. `range_key` picks the uptime window
surfaced (24h/7d/30d; anything else falls back to 24h) and comes back
normalised.
Returns (rows, counts). `counts` has up (degraded included), degraded,
down, pending and paused. A monitor is degraded on its 24h uptime.
"""
_since, range_key = parse_range(range_key)
from steward.core.status import sparkline_svg
monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars())
ids = [m.id for m in monitors]
recent = await _last_n_results(db, ids)
uptime = await _uptime_by_monitor(db, ids)
# Map the selected range to the uptime bucket we surface in the list.
bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h")
now = datetime.now(timezone.utc)
data = []
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
uptime_pct = uptime.get(m.id, {}).get(bucket)
windows = uptime.get(m.id, {})
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
times = [r.response_ms for r in rows if r.response_ms is not None]
data.append({
"monitor": m,
"latest": latest,
"state": row_state(m, latest, uptime_pct, thresholds),
"state": row_state(m, latest, windows.get("24h"), thresholds),
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
"uptime_pct": uptime_pct,
"uptime": uptime.get(m.id, {}),
"spark": sparkline_svg(times, width=56, height=16, stroke="currentColor") if len(times) > 1 else "",
"uptime": windows,
"tls_days": tls_days,
"down_since": None,
"down_reason": down_reason(latest) if latest is not None and not latest.is_up else None,
@@ -215,7 +211,7 @@ async def monitor_rows(db, range_key: str, thresholds: dict):
d["down_since"] = await down_since(db, d["monitor"].id)
counts = {k: sum(d["state"] == k for d in data) for k in STATE_ORDER}
counts["up"] += counts["degraded"]
return problems_first(data), counts, range_key
return problems_first(data), counts
async def down_since(db, monitor_id: str):
+9
View File
@@ -324,6 +324,15 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.ping-row-paused { opacity:0.7; }
.row-actions { display:flex; gap:0.35rem; flex-shrink:0; }
.ping-meta { min-width:120px; max-width:180px; flex-shrink:1; overflow:hidden; }
/* Monitors list columns (#5270): fixed widths under one header row. */
.mon-head { font-size:var(--fs-size-tiny); color:var(--text-dim); text-transform:uppercase; letter-spacing:var(--fs-tracking-tiny); padding-top:0; }
.mon-head .ping-pills { display:block; }
.ping-meta.mon-col-name { width:14rem; min-width:0; max-width:none; flex-shrink:0; }
.mon-latest { width:7.5rem; flex-shrink:0; text-align:right; font-size:0.8rem; font-variant-numeric:tabular-nums; white-space:nowrap; }
.mon-spark { color:var(--text-dim); margin-right:0.35rem; }
.mon-up { width:4.2rem; flex-shrink:0; text-align:right; font-size:0.8rem; font-variant-numeric:tabular-nums; }
.mon-tls { width:3.5rem; flex-shrink:0; text-align:right; font-size:0.78rem; font-variant-numeric:tabular-nums; }
.mon-act { width:6.6rem; justify-content:flex-end; }
.ping-name { font-size:0.875rem; font-weight:500; display:block; overflow-wrap:anywhere; }
a.ping-name:hover { text-decoration:underline; }
/* Dashboard widget grid — Gridstack-rendered. Static (positioned but inert) in
+2 -1
View File
@@ -1 +1,2 @@
# steward/status/ — unified monitor-status surface (page + dashboard widget)
# steward/status/ — the dashboard Overview widget (/status/widget). The Status
# page it once sat beside was folded into Monitors (#5270).
+1 -26
View File
@@ -4,7 +4,7 @@ from sqlalchemy import func, select
from steward.alerts.service import active_alerts, alert_label
from steward.auth.middleware import require_role
from steward.core.status import collect_status, sparkline_svg
from steward.core.status import collect_status
from steward.models.hosts import Host
from steward.models.users import UserRole
@@ -18,31 +18,6 @@ def _summarize(entries) -> tuple[int, int, int]:
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 — 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,
alerts=alerts, problems=[e for e in entries if e.status == "down"],
)
@status_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
-1
View File
@@ -46,7 +46,6 @@
<div class="nav-group">
<div class="nav-group-label">Overview</div>
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
<a href="/status" {% if _p.startswith('/status') %}class="active" aria-current="page"{% endif %}>Status</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Infrastructure</div>
+1 -3
View File
@@ -3,14 +3,12 @@
{% block title %}Monitors — Steward{% endblock %}
{% block content %}
{% call page_header("Monitors", "Checked every " ~ poll_interval ~ "s. Problems first.") %}
{% include "_time_range.html" %}
{% if session.user_role in ['operator', 'admin'] %}<a class="btn btn-sm" href="/monitors/new">Add monitor</a>{% endif %}
{% endcall %}
<div id="monitor-rows"
hx-get="/monitors/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-trigger="load, every {{ poll_interval }}s"
hx-swap="innerHTML">
<div class="card"><p class="field-hint">Loading…</p></div>
</div>
+25 -14
View File
@@ -1,5 +1,8 @@
{# HTMX fragment — the Monitors list: counts, then every monitor, problems first
(service.problems_first). Colours come from Settings → Thresholds. #}
(service.problems_first), under one header: the last 30 checks, latest
response with its trend, uptime over 24h / 7d / 30d and TLS days. This is
the one home for monitor state; the Status page it replaced is gone (#5270).
Colours come from Settings → Thresholds. #}
{% from "_macros.html" import stat_tile, status_badge, status_dot, icon_button, empty_state %}
<div class="stat-strip">
{{ stat_tile("Down", counts.down, state="down" if counts.down else "inactive") }}
@@ -10,13 +13,21 @@
</div>
{% if monitor_data %}
<div class="card ping-card">
{% set can_edit = session.user_role in ['operator', 'admin'] %}
<div class="card ping-card">
<div class="ping-row mon-head" aria-hidden="true">
<span class="ping-meta mon-col-name">Monitor</span>
<span class="ping-pills">Last 30 checks</span>
<span class="mon-latest">Latest</span>
<span class="mon-up">24h</span><span class="mon-up">7d</span><span class="mon-up">30d</span>
<span class="mon-tls">TLS</span>
{% if can_edit %}<span class="mon-act"></span>{% endif %}
</div>
{% for d in monitor_data %}
{% set m = d.monitor %}
{% set latest = d.latest %}
<div class="ping-row{% if d.state == 'paused' %} ping-row-paused{% endif %}">
<div class="ping-meta" style="min-width:160px;max-width:260px;">
<div class="ping-meta mon-col-name">
<div style="display:flex;align-items:baseline;gap:0.4rem;">
{{ status_dot(d.state) }}
<a class="ping-name" href="/monitors/{{ m.id }}">{{ m.name }}</a>
@@ -34,28 +45,27 @@
{% for h in d.heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
</div>
<div class="ping-cur">
<div class="mon-latest">
{% if d.state in ('down', 'paused', 'pending') %}{{ status_badge(d.state) }}
{% elif latest.response_ms is not none %}
{% if d.spark %}<span class="mon-spark" title="Response time over the last 30 checks">{{ d.spark|safe }}</span>{% endif %}
<span class="tone-{{ status_class(latest.response_ms, 'latency') }}">{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% elif m.type == 'dns' and latest.resolved_ip %}<span class="tone-ok">{{ latest.resolved_ip }}</span>
{% else %}{{ status_badge('up') }}{% endif %}
</div>
<div style="min-width:4.5rem;text-align:right;font-size:0.78rem;font-variant-numeric:tabular-nums;">
{% if d.uptime_pct is not none %}<span class="tone-{{ status_class(d.uptime_pct, 'uptime') }}">{{ "%.1f"|format(d.uptime_pct) }}%</span>{% else %}<span class="tone-unknown">—</span>{% endif %}
<span class="tone-dim" style="font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
</div>
{% for k in ("24h", "7d", "30d") %}{% set u = d.uptime.get(k) %}
<div class="mon-up">{% if u is none %}<span class="tone-unknown">—</span>{% else %}<span class="tone-{{ status_class(u, 'uptime') }}">{{ "%.2f"|format(u) }}%</span>{% endif %}</div>
{% endfor %}
{% if d.tls_days is not none %}
<div style="min-width:4rem;text-align:right;font-size:0.72rem;">
{% if d.tls_days < 0 %}<span class="tone-crit" title="Certificate expired">cert expired</span>
{% else %}<span class="tone-{{ status_class(d.tls_days, 'tls_days') }}" title="TLS certificate expires in {{ d.tls_days|round|int }} days">cert {{ d.tls_days|round|int }}d</span>{% endif %}
<div class="mon-tls">
{% if d.tls_days is none %}<span class="tone-unknown">—</span>
{% elif d.tls_days < 0 %}<span class="tone-crit" title="Certificate expired">expired</span>
{% else %}<span class="tone-{{ status_class(d.tls_days, 'tls_days') }}" title="TLS certificate expires in {{ d.tls_days|round|int }} days">{{ d.tls_days|round|int }}d</span>{% endif %}
</div>
{% endif %}
{% if can_edit %}
<div class="row-actions">
<div class="row-actions mon-act">
{{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }}
<form method="post" action="/monitors/{{ m.id }}/toggle">{{ csrf_field() }}
{{ icon_button("⏸" if m.enabled else "▶", ("Pause " if m.enabled else "Resume ") ~ m.name, type="submit") }}
@@ -68,6 +78,7 @@
</div>
{% endfor %}
</div>
<p class="field-hint" style="margin-top:0.6rem;">Last 30 checks: newest on the right; hover one for its result. Uptime is over the rolling window. TLS is days until the certificate expires (HTTPS monitors).</p>
{% else %}
<div class="card">{{ empty_state("No monitors yet. Add one, or attach checks to a host from its page.",
"Add monitor" if session.user_role in ['operator', 'admin'] else none, "/monitors/new") }}</div>
+2 -2
View File
@@ -61,8 +61,8 @@
</div>
<p style="color:var(--text-muted);font-size:0.82rem;margin:0 0 0.75rem;max-width:720px;">
Built-in ways to monitor your hosts (agent metrics, SNMP, Docker). These are
facets of a host — you'll see their data in the <a href="/hosts/">Hosts</a> and
<a href="/status">Status</a> sections, not as separate areas. On by default.
facets of a host — you'll see their data on each host in <a href="/hosts/">Hosts</a>,
not as separate areas. On by default.
</p>
{% if capabilities %}
<div style="display:grid;gap:0.6rem;max-width:720px;margin-bottom:1.5rem;">
-12
View File
@@ -1,12 +0,0 @@
{% 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 %}
-108
View File
@@ -1,108 +0,0 @@
{# 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 -%}<span class="tone-unknown">—</span>
{%- else -%}<span class="tone-{{ status_class(pct, 'uptime') }}">{{ "%.2f"|format(pct) }}%</span>{%- endif -%}
{%- endmacro %}
{% macro since(e) -%}
{%- if e.since -%}{{ "for at least " if e.since_at_least else "since " }}{{ e.since|ago }}{%- endif -%}
{%- endmacro %}
{# ── What is wrong ───────────────────────────────────────────────────────── #}
{% if problems or alerts %}
<section class="card problem-card" aria-labelledby="status-problems">
<h2 id="status-problems" class="section-title" style="margin-bottom:0.6rem;">Needs attention</h2>
<ul class="problem-list">
{% for e in problems %}
<li>
{{ status_badge('down') }}
{% if e.detail_url %}<a href="{{ e.detail_url }}">{{ e.name }}</a>{% else %}<strong>{{ e.name }}</strong>{% endif %}
<span class="problem-detail">{{ e.kind|upper }} · {{ e.target }} · down {{ since(e) }}</span>
</li>
{% endfor %}
{% for state, rule in alerts %}
<li>
{{ status_badge('no_data' if state.no_data_since and state.state.value == 'inactive' else state.state) }}
<a href="/alerts/history?rule={{ rule.id }}">{{ rule.name }}</a>
<span class="problem-detail">{{ rule.resource_name }} {{ rule_condition(rule) }} · since {{ (state.no_data_since if state.state.value == 'inactive' else state.last_transition_at)|ago }}</span>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{# ── Counts ──────────────────────────────────────────────────────────────── #}
<div class="stat-strip">
{{ 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") }}
</div>
{% if not entries %}
<div class="card">{{ empty_state("No monitors yet. Add a host (ping or DNS) or an HTTP monitor to fill the board.", "Add a monitor", "/monitors/new") }}</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:11rem;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:11rem;flex-shrink:0;min-width:0;">
<div style="display:flex;align-items:baseline;gap:0.4rem;">
{{ status_dot(e.status) }}
{% if e.detail_url %}<a href="{{ e.detail_url }}" style="font-size:0.85rem;font-weight:500;color:var(--text);overflow-wrap:anywhere;">{{ e.name }}</a>
{% else %}<span style="font-size:0.85rem;font-weight:500;overflow-wrap:anywhere;">{{ e.name }}</span>{% endif %}
</div>
<div style="color:var(--text-dim);font-size:0.7rem;overflow-wrap:anywhere;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 pill-{{ 'down' if hb.state == 'down' else 'up' }}" 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' %}{{ status_badge('down') }}
{% 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' %}{{ status_badge('up') }}
{% 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 class="tone-unknown">—</span>
{% elif e.tls_days < 0 %}<span class="tone-crit" title="Certificate expired">expired</span>
{% else %}<span class="tone-{{ status_class(e.tls_days, 'tls_days') }}" title="TLS certificate expires in {{ e.tls_days|round|int }} days">{{ 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 %}
+12 -9
View File
@@ -49,7 +49,8 @@ def test_problems_come_first_then_by_name():
def _row(m, state, latest, uptime=None, tls_days=None):
return {"monitor": m, "state": state, "latest": latest, "uptime_pct": uptime, "tls_days": tls_days,
return {"monitor": m, "state": state, "latest": latest, "uptime": {"24h": uptime} if uptime is not None else {},
"tls_days": tls_days, "spark": "",
"heartbeat": [{"state": "up" if latest.is_up else "down", "title": "t"}] if latest else []}
@@ -62,14 +63,18 @@ async def test_the_list_counts_then_shows_problems_first(app):
counts = {"up": 1, "degraded": 0, "down": 1, "pending": 0, "paused": 1}
async with app.test_request_context("/monitors/rows"):
session["user_role"] = "operator"
page = await render_template("monitors/rows.html", monitor_data=data, counts=counts, range_key="24h")
page = await render_template("monitors/rows.html", monitor_data=data, counts=counts)
assert page.index('<span class="stat-tile-label">Down</span>') < page.index("ping-row")
assert page.index('href="/monitors/m2"') < page.index('href="/monitors/m1"') < page.index('href="/monitors/m3"')
assert "HTTP · https://web/" in page and "Ping · https://nas/" in page
assert '<span class="tone-ok">30 ms</span>' in page
assert '<span class="tone-crit">90.0%</span>' in page and '<span class="tone-ok">100.0%</span>' in page
assert 'class="tone-crit" title="TLS certificate expires in 2 days">cert 2d' in page
assert '<span class="tone-crit">90.00%</span>' in page and '<span class="tone-ok">100.00%</span>' in page
assert 'class="tone-crit" title="TLS certificate expires in 2 days">2d' in page
# One header row names every column (#5270, absorbing the Status page).
head = page[page.index("mon-head"):page.index('href="/monitors/m2"')]
for label in ("Monitor", "Last 30 checks", "Latest", "24h", "7d", "30d", "TLS"):
assert f">{label}<" in head, label
assert '<span class="badge badge-crit">Down</span>' in page and '<span class="badge badge-dim">Paused</span>' in page
assert 'aria-label="Delete api"' in page and 'data-confirm="Delete monitor api?"' in page
assert "onclick" not in page and not hex_colours(page), hex_colours(page)
@@ -78,9 +83,8 @@ async def test_the_list_counts_then_shows_problems_first(app):
async def test_a_viewer_gets_no_row_actions_and_an_empty_board_says_so(app):
async with app.test_request_context("/monitors/rows"):
page = await render_template("monitors/rows.html", monitor_data=[_row(_mon("web"), "up", _check())],
counts={"up": 1, "degraded": 0, "down": 0, "pending": 0, "paused": 0},
range_key="24h")
empty = await render_template("monitors/rows.html", monitor_data=[], range_key="24h",
counts={"up": 1, "degraded": 0, "down": 0, "pending": 0, "paused": 0})
empty = await render_template("monitors/rows.html", monitor_data=[],
counts=dict.fromkeys(("up", "degraded", "down", "pending", "paused"), 0))
assert "row-actions" not in page
assert "No monitors yet" in empty and "/monitors/new" not in empty
@@ -133,8 +137,7 @@ async def test_a_down_monitor_says_since_when_and_why_in_the_list(app):
down_reason="connection refused")
async with app.test_request_context("/monitors/rows"):
page = await render_template("monitors/rows.html", monitor_data=[row],
counts={"up": 0, "degraded": 0, "down": 1, "pending": 0, "paused": 0},
range_key="24h")
counts={"up": 0, "degraded": 0, "down": 1, "pending": 0, "paused": 0})
page = " ".join(page.split())
assert re.search(r'Down <time [^>]*>12m</time> · connection refused', page), page
+3 -47
View File
@@ -1,13 +1,11 @@
"""The Status page says what is wrong, and since when, before anything else
(#5120)."""
"""How long a monitor has been in its state (#5120). The Status page that
first showed it was folded into Monitors (#5270)."""
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
from steward.core.status import streak_start
T0 = datetime(2026, 10, 6, 18, 0, tzinfo=timezone.utc)
@@ -26,45 +24,3 @@ def _checks(*ups):
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("</section>")]
assert "shop-api" in problem_card and "nas" not in problem_card
assert "down since <time" in problem_card and ">12m ago</time>" in problem_card
assert "CPU high" in problem_card and "web01 CPU &gt; 90%" in problem_card and ">4m ago</time>" in problem_card
assert '<span class="badge badge-crit">Firing</span>' in problem_card
# Uptime and TLS coloured by Settings → Thresholds, not by literals.
assert '<span class="tone-crit">93.00%</span>' in page and '<span class="tone-ok">100.00%</span>' 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, [], [])
-1
View File
@@ -12,7 +12,6 @@ from tests._templates import REPO, template_files
TOP_LEVEL = {
"steward/templates/dashboard/index.html",
"steward/templates/status/index.html",
"steward/templates/hosts/list.html",
"steward/templates/monitors/index.html",
"steward/templates/alerts/list.html",