feat(monitors): problems first, a page per monitor, adding on its own page (#5121)
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 1m23s
CI / integration (push) Successful in 1m12s
CI / publish (push) Skipped

The Monitors list now opens with a count strip (down, up with how many
degraded, pending, paused) and sorts down, degraded, pending, up, paused,
by name within each. Degraded is up but worth a look: uptime at or below
its warning floor, or the latest response past its critical cutoff, both
from Settings → Thresholds; a slow-but-tolerable reply is coloured, not
sorted.

A monitor had no page of its own: every link went to the list. Now
/monitors/<id> shows its state and since when, latency, uptime for 24h/7d/
30d, TLS days, the last checks, recent failures with their errors, and its
settings (HTTP header names, never their values). Status links to it, and
adding or editing returns to it.

Adding is its own page (/monitors/new, form.html shared with edit) behind
an "Add monitor" button; a refused add re-renders the form with the error
and what was typed instead of a bare 400 text. Row actions are labelled
icon buttons, type names read Ping/TCP/DNS/HTTP (|monitor_type), and the
heartbeat uses pill-up/pill-down, so HEX_CEILING drops to 56.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 17:54:30 -04:00
co-authored by Claude Opus 5.5
parent fda54a3c31
commit e595bd922a
16 changed files with 441 additions and 172 deletions
+42 -10
View File
@@ -12,6 +12,7 @@ from datetime import datetime, timezone
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
@@ -128,12 +129,42 @@ async def check_name_free(db, mtype: str, name: str, exclude_id: str | None = No
"alert rules go by that name, so pick a different one.")
async def monitor_rows(db, range_key: str):
"""Per-monitor state: latest result, heartbeat strip, uptime and TLS days.
# Where each state sorts on the Monitors page: what needs looking at first.
STATE_ORDER = {"down": 0, "degraded": 1, "pending": 2, "up": 3, "paused": 4}
Returns (rows, up, down, pending, range_key). `range_key` picks the uptime
window surfaced (24h/7d/30d; anything else falls back to 24h) and comes
back normalised.
def row_state(monitor: Monitor, latest, uptime_pct, thresholds: dict) -> str:
"""paused, pending (never checked), down, degraded or up.
Degraded is up but worth a look: uptime over the window at or below its
warning cutoff, or the latest response time past its critical one
(Settings → Thresholds). A slow-but-tolerable reply is coloured, not sorted.
"""
if not monitor.enabled:
return "paused"
if latest is None:
return "pending"
if not latest.is_up:
return "down"
if (status_class(uptime_pct, "uptime", thresholds) in ("warn", "crit")
or status_class(latest.response_ms, "latency", thresholds) == "crit"):
return "degraded"
return "up"
def problems_first(rows: list[dict]) -> list[dict]:
"""Down, then degraded, then pending, up and paused; by name within each."""
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.
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.
"""
_since, range_key = parse_range(range_key)
monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars())
@@ -148,21 +179,22 @@ async def monitor_rows(db, range_key: str):
for m in monitors:
rows = recent.get(m.id, [])
latest = rows[-1] if rows else None
uptime_pct = uptime.get(m.id, {}).get(bucket)
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - now).total_seconds() / 86400.0
data.append({
"monitor": m,
"latest": latest,
"state": row_state(m, latest, uptime_pct, thresholds),
"heartbeat": [{"state": "up" if r.is_up else "down",
"title": _heartbeat_title(m.type, r)} for r in rows],
"uptime_pct": uptime.get(m.id, {}).get(bucket),
"uptime_pct": uptime_pct,
"tls_days": tls_days,
})
up = sum(1 for d in data if d["latest"] and d["latest"].is_up)
down = sum(1 for d in data if d["latest"] and not d["latest"].is_up)
pending = sum(1 for d in data if not d["latest"])
return data, up, down, pending, range_key
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
async def down_since(db, monitor_id: str):