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
+2
View File
@@ -143,6 +143,8 @@ def create_app(
# The status and layout macros in _macros.html read these.
app.jinja_env.globals["status_info"] = _fmt.status_info
app.jinja_env.globals["required"] = _fmt.required
from .models.monitors import MONITOR_TYPE_LABELS as _type_labels
app.jinja_env.filters["monitor_type"] = lambda t: _type_labels.get(t, str(t).upper())
# Forged-request protection: every POST form renders csrf_field(), HTMX and
# fetch() send csrf_token() as X-CSRF-Token, and one check refuses any
+2
View File
@@ -109,10 +109,12 @@ STATES: dict[str, tuple[str, str]] = {
"crit": ("crit", "Critical"),
"warn": ("warn", "Warning"),
"pending": ("warn", "Pending"),
"degraded": ("warn", "Degraded"),
"stale": ("warn", "Stale"),
"no_data": ("warn", "No data"),
"acknowledged": ("info", "Acknowledged"),
"inactive": ("dim", "Inactive"),
"paused": ("dim", "Paused"),
"unknown": ("dim", "Unknown"),
}
+1 -1
View File
@@ -218,7 +218,7 @@ async def monitor_status_source(db) -> list[StatusEntry]:
"title": _heartbeat_title(m.type, r)} for r in rows],
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/",
tls_days=tls_days, detail_url=f"/monitors/{m.id}",
since=since, since_at_least=since_at_least,
))
return entries
+2
View File
@@ -23,6 +23,8 @@ class MonitorType(str, enum.Enum):
# DROP/ADD CONSTRAINT (family rule 36) instead of the heavier ALTER TYPE ADD
# VALUE a native enum would force.
MONITOR_TYPE_VALUES = tuple(t.value for t in MonitorType)
# How a page names each type.
MONITOR_TYPE_LABELS = {"icmp": "Ping", "tcp": "TCP", "dns": "DNS", "http": "HTTP"}
_TYPE_CHECK = "type IN ('icmp', 'tcp', 'dns', 'http')"
+64 -12
View File
@@ -15,7 +15,9 @@ from steward.core import renames
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
monitors_bp = Blueprint("monitors", __name__, url_prefix="/monitors")
@@ -49,7 +51,6 @@ async def index():
"monitors/index.html",
poll_interval=poll_interval,
current_range=request.args.get("range", DEFAULT_RANGE),
types=list(MonitorType),
)
@@ -57,11 +58,11 @@ async def index():
@require_role(UserRole.viewer)
async def rows():
async with current_app.db_sessionmaker() as db:
data, up, down, pending, range_key = await monitor_svc.monitor_rows(
db, request.args.get("range", DEFAULT_RANGE))
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, up=up, down=down, pending=pending, range_key=range_key,
monitor_data=data, counts=counts, range_key=range_key,
)
@@ -76,7 +77,7 @@ async def add_monitor():
check_interval_seconds=form.get("check_interval_seconds"),
opts=_form_opts(form, mtype))
except ValueError as exc:
return str(exc), 400
return await _new_form(form, mtype, str(exc)), 400
host_id = form.get("host_id", "").strip() or None
monitor = Monitor(id=str(uuid.uuid4()), host_id=host_id, enabled=True,
@@ -86,13 +87,58 @@ async def add_monitor():
try:
await monitor_svc.check_name_free(db, monitor.type, monitor.name)
except ValueError as exc:
return str(exc), 400
return await _new_form(form, mtype, str(exc)), 400
db.add(monitor)
# A host-scoped add returns to that host's hub; otherwise the monitors page.
# A host-scoped add returns to that host's hub; otherwise the new monitor's page.
if host_id:
return redirect(url_for("hosts.host_detail", host_id=host_id))
return redirect(url_for("monitors.index"))
return redirect(url_for("monitors.detail", monitor_id=monitor.id))
async def _new_form(form, mtype: str, error: str | None = None) -> str:
"""The add form, holding what was typed when `error` sends it back."""
values = {k: form.get(k, "") for k in ("name", "target", "host_id")}
values["check_interval_seconds"] = form.get("check_interval_seconds", 0)
return await render_template(
"monitors/form.html", monitor=None, values=values,
mtype=mtype if mtype in MONITOR_TYPE_VALUES else MonitorType.icmp.value,
config=_form_opts(form, mtype) if error and mtype in MONITOR_TYPE_VALUES else {},
types=list(MonitorType), error=error,
)
@monitors_bp.get("/new")
@require_role(UserRole.operator)
async def new_form():
return await _new_form({"host_id": request.args.get("host_id", "")}, MonitorType.icmp.value)
@monitors_bp.get("/<monitor_id>")
@require_role(UserRole.viewer)
async def detail(monitor_id: str):
async with current_app.db_sessionmaker() as db:
m = await db.get(Monitor, monitor_id)
if not m:
return redirect(url_for("monitors.index"))
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
failures = await monitor_svc.recent_failures(db, m.id)
host = await db.get(Host, m.host_id) if m.host_id else None
thresholds = current_app.config.get("THRESHOLDS", {})
latest = st["latest"]
since, since_at_least = streak_start(st["recent"])
if st["down_since"]:
since, since_at_least = st["down_since"], False
tls_days = None
if latest and latest.tls_expires_at:
tls_days = (latest.tls_expires_at - datetime.now(timezone.utc)).total_seconds() / 86400.0
return await render_template(
"monitors/detail.html", monitor=m, host=host, latest=latest, failures=failures,
state=monitor_svc.row_state(m, latest, st["uptime"].get("24h"), thresholds),
since=since, since_at_least=since_at_least, uptime=st["uptime"], tls_days=tls_days,
heartbeat=[{"state": "up" if r.is_up else "down", "title": _heartbeat_title(m.type, r)}
for r in st["recent"]],
)
@monitors_bp.get("/<monitor_id>/edit")
@@ -103,7 +149,8 @@ async def edit_form(monitor_id: str):
if not m:
return redirect(url_for("monitors.index"))
return await render_template(
"monitors/edit.html", monitor=m, config=m.config, types=list(MonitorType),
"monitors/form.html", monitor=m, values=None, mtype=m.type, config=m.config,
types=list(MonitorType), error=None,
)
@@ -135,7 +182,7 @@ async def edit_monitor(monitor_id: str):
if moved:
await audit_request("monitor.updated", "monitor", m.name,
{"monitor_id": m.id, "renamed_from": old_name, "carried": moved})
return redirect(url_for("monitors.index"))
return redirect(url_for("monitors.detail", monitor_id=m.id))
@monitors_bp.post("/<monitor_id>/toggle")
@@ -157,7 +204,10 @@ async def delete_monitor(monitor_id: str):
m = await db.get(Monitor, monitor_id)
if m:
await db.delete(m)
return redirect(request.referrer or url_for("monitors.index"))
referrer = request.referrer or ""
if f"/monitors/{monitor_id}" in referrer:
return redirect(url_for("monitors.index"))
return redirect(referrer or url_for("monitors.index"))
@monitors_bp.get("/widget")
@@ -169,7 +219,9 @@ async def widget():
widget_id = request.args.get("wid", "0")
async with current_app.db_sessionmaker() as db:
data, up, down, pending, _ = await monitor_svc.monitor_rows(db, DEFAULT_RANGE)
data, counts, _ = await monitor_svc.monitor_rows(
db, DEFAULT_RANGE, current_app.config.get("THRESHOLDS", {}))
up, down, pending = counts["up"], counts["down"], counts["pending"]
if type_filter in MONITOR_TYPE_VALUES:
data = [d for d in data if d["monitor"].type == type_filter]
if show_down_only:
+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):
+6
View File
@@ -190,6 +190,7 @@ textarea { resize: vertical; }
.field { display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.9rem; }
.field-label { display: block; margin-bottom: 0; font-size: 0.75rem; color: var(--text-muted); }
.field-hint { font-size: 0.75rem; color: var(--text-dim); }
.field-error { font-size: 0.85rem; color: var(--red); margin-bottom: 0.75rem; }
.input { width: 100%; padding: 0.4rem 0.65rem; background: var(--bg); border: 1px solid var(--border-mid); border-radius: var(--fs-radius-sm); color: var(--text); font-size: 0.88rem; font-family: inherit; }
.input:focus { outline: none; border-color: var(--accent); }
.input:disabled { opacity: var(--fs-disabled-opacity); }
@@ -264,6 +265,9 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.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; }
/* A label/value list: <dl class="kv"><dt>…</dt><dd>…</dd></dl>. */
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.88rem; }
.kv dt { color: var(--text-muted); }
.problem-detail { color: var(--text-muted); font-size: 0.82rem; }
/* Breadcrumbs */
@@ -283,6 +287,8 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
.ping-card { padding: 0.75rem 1.25rem; }
.ping-row { display:flex; align-items:center; gap:1rem; padding:0.45rem 0; border-bottom:1px solid var(--border); }
.ping-row:last-child { border-bottom:none; }
.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; }
.ping-name { font-size:0.875rem; font-weight:500; display:block; overflow-wrap:anywhere; }
a.ping-name:hover { text-decoration:underline; }
+83
View File
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, stat_tile, status_badge, icon_button, empty_state %}
{# One monitor: its state and since when, latency and uptime against Settings →
Thresholds, its last checks, and the failures behind them. #}
{% block title %}{{ monitor.name }} — Monitors — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "")]) }}{% endblock %}
{% block content %}
{% set can_edit = session.user_role in ['operator', 'admin'] %}
{% call page_header(monitor.name, (monitor.type|monitor_type) ~ " · " ~ monitor.target) %}
{% if can_edit %}
{{ icon_button("✎", "Edit " ~ monitor.name, href="/monitors/" ~ monitor.id ~ "/edit") }}
<form method="post" action="/monitors/{{ monitor.id }}/toggle">{{ csrf_field() }}
{{ icon_button("⏸" if monitor.enabled else "▶", ("Pause " if monitor.enabled else "Resume ") ~ monitor.name, type="submit") }}
</form>
<form method="post" action="/monitors/{{ monitor.id }}/delete">{{ csrf_field() }}
{{ icon_button("×", "Delete " ~ monitor.name, type="submit", cls="btn-danger", confirm="Delete monitor " ~ monitor.name ~ "? Its check history goes with it.") }}
</form>
{% endif %}
{% endcall %}
<div class="stat-strip">
{% set since_hint = ((("for at least " if since_at_least else "since ") ~ since|ago) if since and state in ("up", "down", "degraded") else none) %}
{{ stat_tile("State", status_info(state)[1], state=state, hint=since_hint) }}
{% if latest and latest.response_ms is not none %}
{{ stat_tile("Latency", "%.0f ms"|format(latest.response_ms), state=status_class(latest.response_ms, "latency")) }}
{% endif %}
{% for window in ("24h", "7d", "30d") %}
{% set pct = uptime.get(window) %}
{{ stat_tile("Uptime " ~ window, "%.2f%%"|format(pct) if pct is not none else "—", state=status_class(pct, "uptime")) }}
{% endfor %}
{% if tls_days is not none %}
{{ stat_tile("TLS certificate", "expired" if tls_days < 0 else (tls_days|round|int) ~ " days", state="crit" if tls_days < 0 else status_class(tls_days, "tls_days")) }}
{% endif %}
</div>
<section class="card" aria-labelledby="m-checks">
<h2 id="m-checks" class="section-title">Last checks</h2>
{% if heartbeat %}
<div class="ping-pills" style="margin:0.5rem 0;">
{% for _ in range([30 - heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
{% for h in heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
</div>
<p class="field-hint">Newest on the right.{% if latest %} Last checked {{ latest.checked_at|ago }}.{% endif %}</p>
{% else %}
{{ empty_state("Not checked yet. The first check runs on the next scheduler tick." if monitor.enabled else "Paused, and never checked.") }}
{% endif %}
</section>
<section class="card" aria-labelledby="m-failures">
<h2 id="m-failures" class="section-title">Recent failures</h2>
{% if failures %}
<table class="table">
<thead><tr><th>When</th><th>Error</th>{% if monitor.type == "http" %}<th>Status</th>{% endif %}</tr></thead>
<tbody>
{% for f in failures %}
<tr>
<td style="white-space:nowrap;">{{ f.checked_at|ago }}</td>
<td style="overflow-wrap:anywhere;">{{ f.error_msg or "Down" }}</td>
{% if monitor.type == "http" %}<td>{{ f.status_code or "—" }}</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
{{ empty_state("No failed checks on record.") }}
{% endif %}
</section>
<section class="card" aria-labelledby="m-config">
<h2 id="m-config" class="section-title">Settings</h2>
<dl class="kv">
<dt>Checked every</dt>
<dd>{{ monitor.check_interval_seconds|duration if monitor.check_interval_seconds else "the global interval" }}</dd>
{% if host %}<dt>Host</dt><dd><a href="/hosts/{{ host.id }}">{{ host.name }}</a></dd>{% endif %}
{% for key, value in monitor.config.items() %}
{% if value not in (none, "", {}) %}
<dt>{{ key|replace("_", " ")|capitalize }}</dt>
<dd style="overflow-wrap:anywhere;">{{ value|join(", ") if value is mapping else ("yes" if value is sameas true else ("no" if value is sameas false else value)) }}</dd>
{% endif %}
{% endfor %}
</dl>
</section>
{% endblock %}
-47
View File
@@ -1,47 +0,0 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% block title %}Edit Monitor — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Monitors", "/monitors/"), ("Edit monitor", "")]) }}{% endblock %}
{% block content %}
<div style="margin-bottom:1.25rem;">
<h1 class="page-title" style="margin-bottom:0;">Edit Monitor</h1>
</div>
<div class="card" style="max-width:760px;">
<form method="post" action="/monitors/{{ monitor.id }}/edit" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label class="field-label">Type</label>
<input type="text" value="{{ monitor.type | upper }}" disabled class="input">
</div>
<div>
<label class="field-label">Name</label>
<input type="text" name="name" value="{{ monitor.name }}" autocomplete="off" class="input">
</div>
<div>
<label class="field-label">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" value="{{ monitor.target }}" autocomplete="off" required class="input">
</div>
<div>
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="{{ monitor.check_interval_seconds }}" min="0" class="input">
</div>
{{ type_fields(config) }}
</div>
{% if monitor.host_id %}
<p style="font-size:0.78rem;color:var(--text-dim);margin:0;">
Linked to <a href="/hosts/{{ monitor.host_id }}" style="color:var(--accent);">its host</a>.
</p>
{% endif %}
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-sm">Save</button>
<a href="/monitors/" class="btn btn-sm" style="background:transparent;border:1px solid var(--border-mid);">Cancel</a>
</div>
</form>
</div>
{{ toggle_script() }}
<script>mtoggle({{ monitor.type | tojson }});</script>
{% endblock %}
+66
View File
@@ -0,0 +1,66 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{# Add (monitor is none; `values` holds what was typed) or edit a monitor. #}
{% block title %}{{ "Edit " ~ monitor.name if monitor else "Add monitor" }} — Steward{% endblock %}
{% block breadcrumb %}
{%- if monitor -%}
{{ crumbs([("Monitors", "/monitors/"), (monitor.name, "/monitors/" ~ monitor.id), ("Edit", "")]) }}
{%- else -%}
{{ crumbs([("Monitors", "/monitors/"), ("Add monitor", "")]) }}
{%- endif -%}
{% endblock %}
{% block content %}
{% set v = values or {} %}
{{ page_header("Edit monitor" if monitor else "Add monitor") }}
<div class="card" style="max-width:760px;">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<form method="post" action="{{ '/monitors/' ~ monitor.id ~ '/edit' if monitor else '/monitors/add' }}" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
{% if not monitor and v.host_id %}<input type="hidden" name="host_id" value="{{ v.host_id }}">{% endif %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label class="field-label" for="mtype">Type</label>
{% if monitor %}
<input type="text" id="mtype" value="{{ monitor.type|monitor_type }}" disabled class="input">
{% else %}
<select name="type" id="mtype" class="input">
{% for t in types %}
<option value="{{ t.value }}" {% if t.value == mtype %}selected{% endif %}>{{ t.value|monitor_type }}</option>
{% endfor %}
</select>
{% endif %}
</div>
<div>
<label class="field-label" for="m-name">Name</label>
<input type="text" id="m-name" name="name" value="{{ monitor.name if monitor else v.name }}" placeholder="optional" autocomplete="off" class="input">
</div>
<div>
<label class="field-label" for="m-target">Target <span class="tone-crit" aria-hidden="true">*</span></label>
<input type="text" id="m-target" name="target" value="{{ monitor.target if monitor else v.target }}" placeholder="host, IP, or URL" autocomplete="off" required class="input">
</div>
<div>
<label class="field-label" for="m-interval">Interval (s, 0 = global)</label>
<input type="number" id="m-interval" name="check_interval_seconds" value="{{ monitor.check_interval_seconds if monitor else (v.check_interval_seconds or 0) }}" min="0" class="input">
</div>
{{ type_fields(config) }}
</div>
{% if monitor and monitor.host_id %}
<p class="field-hint">Linked to <a href="/hosts/{{ monitor.host_id }}">its host</a>.</p>
{% endif %}
<div style="display:flex;gap:0.5rem;">
<button type="submit" class="btn btn-sm">{{ "Save" if monitor else "Add monitor" }}</button>
<a href="{{ '/monitors/' ~ monitor.id if monitor else '/monitors/' }}" class="btn btn-sm btn-ghost">Cancel</a>
</div>
</form>
</div>
{{ toggle_script() }}
<script>
(function () {
var sel = document.getElementById('mtype');
mtoggle({{ mtype|tojson }});
if (sel.tagName === 'SELECT') sel.addEventListener('change', function () { mtoggle(sel.value); });
})();
</script>
{% endblock %}
+11 -52
View File
@@ -1,58 +1,17 @@
{% extends "base.html" %}
{% from "monitors/_fields.html" import type_fields, toggle_script %}
{% from "_macros.html" import page_header %}
{% block title %}Monitors — Steward{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:1.5rem;gap:1rem;flex-wrap:wrap;">
<div style="display:flex;align-items:baseline;gap:1rem;">
<h1 class="page-title" style="margin-bottom:0;">Monitors</h1>
<span style="color:var(--text-muted);font-size:0.85rem;">polling every {{ poll_interval }}s</span>
</div>
{% 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-swap="innerHTML">
<div class="card"><p class="field-hint">Loading…</p></div>
</div>
{# ── Add monitor ──────────────────────────────────────────────────────────── #}
<div class="card" style="margin-bottom:1.25rem;">
<div class="section-title" style="margin-bottom:0.75rem;">Add Monitor</div>
<form method="post" action="/monitors/add" style="display:grid;gap:0.6rem;">{{ csrf_field() }}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:0.6rem;align-items:end;">
<div>
<label class="field-label">Type</label>
<select name="type" id="mtype" onchange="mtoggle(this.value)" class="input">
{% for t in types %}
<option value="{{ t.value }}" {% if t.value == 'icmp' %}selected{% endif %}>{{ t.value | upper }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="field-label">Name</label>
<input type="text" name="name" placeholder="optional" autocomplete="off" class="input">
</div>
<div>
<label class="field-label">Target <span style="color:var(--red);">*</span></label>
<input type="text" name="target" placeholder="host, IP, or URL" autocomplete="off" required class="input">
</div>
<div>
<label class="field-label">Interval (s, 0=global)</label>
<input type="number" name="check_interval_seconds" value="0" min="0" class="input">
</div>
{{ type_fields() }}
</div>
<div><button type="submit" class="btn btn-sm">Add Monitor</button></div>
</form>
</div>
{# ── Live monitor rows ────────────────────────────────────────────────────── #}
<div class="card ping-card">
<div id="monitor-rows"
hx-get="/monitors/rows"
hx-trigger="load, every {{ poll_interval }}s, rangeChange from:body"
hx-include="#time-range"
hx-swap="innerHTML">
<div style="color:var(--text-muted);font-size:0.9rem;padding:2rem;">Loading…</div>
</div>
</div>
{{ toggle_script() }}
<script>mtoggle(document.getElementById('mtype').value);</script>
{% endblock %}
+42 -47
View File
@@ -1,74 +1,69 @@
{# HTMX fragment — monitor rows (list page). #}
{# HTMX fragment — the Monitors list: counts, then every monitor, problems first
(service.problems_first). 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") }}
{{ stat_tile("Up", counts.up, state="up" if counts.up else "inactive",
hint=(counts.degraded ~ " degraded") if counts.degraded else none) }}
{{ stat_tile("Pending", counts.pending, state="pending" if counts.pending else "inactive") }}
{{ stat_tile("Paused", counts.paused, state="paused") }}
</div>
{% if monitor_data %}
<div class="card ping-card">
{% set can_edit = session.user_role in ['operator', 'admin'] %}
{% for d in monitor_data %}
{% set m = d.monitor %}
{% set latest = d.latest %}
<div class="ping-row" {% if not m.enabled %}style="opacity:0.5;"{% endif %}>
<div class="ping-meta" style="min-width:160px;max-width:240px;">
<span class="badge" style="font-size:0.62rem;vertical-align:middle;">{{ m.type | upper }}</span>
{% if m.host_id %}
<a class="ping-name" href="/hosts/{{ m.host_id }}" title="{{ m.name }}"
style="color:inherit;text-decoration:none;">{{ m.name }}</a>
{% else %}
<span class="ping-name" title="{{ m.name }}">{{ m.name }}</span>
{% endif %}
<span class="ping-addr" title="{{ m.target }}">{{ m.target }}</span>
<div class="ping-row{% if d.state == 'paused' %} ping-row-paused{% endif %}">
<div class="ping-meta" style="min-width:160px;max-width:260px;">
<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>
</div>
<span class="ping-addr" title="{{ m.target }}">{{ m.type|monitor_type }} · {{ m.target }}</span>
</div>
<div class="ping-pills">
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}
<span class="pill pill-empty" title="No data"></span>
{% endfor %}
{% for h in d.heartbeat %}
<span class="pill" title="{{ h.title }}"
style="background:{{ '#1a6632' if h.state == 'up' else '#6a1515' }};"></span>
{% endfor %}
{% for _ in range([30 - d.heartbeat|length, 0]|max) %}<span class="pill pill-empty" title="No data"></span>{% endfor %}
{% for h in d.heartbeat %}<span class="pill pill-{{ h.state }}" title="{{ h.title }}"></span>{% endfor %}
</div>
<div class="ping-cur">
{% if latest is none %}
<span class="ping-cur-nd">—</span>
{% elif not latest.is_up %}
<span class="ping-cur-down">DOWN</span>
{% if d.state in ('down', 'paused', 'pending') %}{{ status_badge(d.state) }}
{% elif latest.response_ms is not none %}
<span style="{{ threshold_style(latest.response_ms, 'latency') or 'color:var(--green);' }}">
{{ "%.0f"|format(latest.response_ms) }} ms</span>
{% elif m.type == 'dns' and latest.resolved_ip %}
<span class="ping-cur-good" style="font-size:0.72rem;">{{ latest.resolved_ip }}</span>
{% else %}
<span class="ping-cur-good">UP</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;{{ threshold_style(d.uptime_pct, 'uptime') if d.uptime_pct is not none else 'color:var(--text-dim);' }}">
{% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %}
<span style="color:var(--text-dim);font-size:0.7rem;margin-left:0.15rem;">{{ range_key }}</span>
<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>
{% if d.tls_days is not none %}
<div title="TLS certificate expiry" style="min-width:4rem;text-align:right;font-size:0.72rem;{{ 'color:var(--red);' if d.tls_days < 14 else ('color:var(--yellow);' if d.tls_days < 30 else 'color:var(--text-dim);') }}">
cert {{ d.tls_days|round|int }}d
<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>
{% endif %}
{% if session.user_role in ['operator', 'admin'] %}
<div style="display:flex;gap:0.35rem;flex-shrink:0;">
<a href="/monitors/{{ m.id }}/edit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">Edit</a>
<form method="post" action="/monitors/{{ m.id }}/toggle" style="display:inline;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;">
{{ "Pause" if m.enabled else "Resume" }}</button>
{% if can_edit %}
<div class="row-actions">
{{ 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") }}
</form>
<form method="post" action="/monitors/{{ m.id }}/delete" style="display:inline;"
data-confirm="Delete monitor {{ m.name }}?">{{ csrf_field() }}
<button type="submit" class="btn btn-sm" style="padding:0.2rem 0.5rem;font-size:0.72rem;color:var(--red);">✕</button>
<form method="post" action="/monitors/{{ m.id }}/delete">{{ csrf_field() }}
{{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
</form>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p style="color:var(--text-muted);font-size:0.9rem;padding:1rem 0;">
No monitors yet. Add one above, or attach checks to a host from the
<a href="/hosts/" style="color:var(--accent);">Hosts</a> page.
</p>
<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>
{% endif %}
+1 -1
View File
@@ -1,7 +1,7 @@
{# monitors/widget.html — dashboard widget: unified monitor summary #}
{% if not monitor_data and up == 0 and down == 0 and pending == 0 %}
<div style="color:var(--text-muted);font-size:0.85rem;padding:0.5rem 0;">
No monitors configured. <a href="/monitors/">Add monitors →</a>
No monitors configured. <a href="/monitors/new">Add a monitor →</a>
</div>
{% else %}
+1 -1
View File
@@ -41,7 +41,7 @@
</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/") }}</div>
<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 #}
+117
View File
@@ -0,0 +1,117 @@
"""The Monitors page leads with what needs looking at (#5121): a count strip,
problems first, each monitor on a page of its own, adding behind a button."""
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
import pytest
from quart import render_template, session
from steward.core.settings import to_thresholds_cfg
from steward.monitors.service import problems_first, row_state
from tests.test_style_tokens import hex_colours
TH = to_thresholds_cfg({}) # latency warn 50 / crit 200 ms; uptime warn 99 / crit 95 %
NOW = datetime.now(timezone.utc)
def _mon(name, mtype="http", enabled=True, mid=None):
return SimpleNamespace(id=mid or name, name=name, type=mtype, target=f"https://{name}/",
enabled=enabled, host_id=None, check_interval_seconds=0, config={})
def _check(up=True, ms=20.0, ago_min=1):
return SimpleNamespace(is_up=up, response_ms=ms, resolved_ip=None, status_code=200 if up else 503,
error_msg=None if up else "503 Service Unavailable", tls_expires_at=None,
checked_at=NOW - timedelta(minutes=ago_min))
@pytest.mark.parametrize("enabled, latest, uptime, state", [
(False, _check(False), 50.0, "paused"), # paused wins over everything
(True, None, None, "pending"),
(True, _check(False), 100.0, "down"),
(True, _check(ms=20), 100.0, "up"),
(True, _check(ms=120), 100.0, "up"), # slow but tolerable: coloured, not sorted
(True, _check(ms=250), 100.0, "degraded"), # past the latency critical cutoff
(True, _check(ms=20), 98.0, "degraded"), # uptime under its warning floor
(True, _check(ms=20), None, "up"), # no uptime yet is not a problem
])
def test_row_state(enabled, latest, uptime, state):
assert row_state(_mon("m", enabled=enabled), latest, uptime, TH) == state
def test_problems_come_first_then_by_name():
rows = [{"monitor": _mon(n), "state": s} for n, s in [
("alpha", "up"), ("Zed", "down"), ("beta", "paused"), ("gamma", "degraded"),
("delta", "pending"), ("able", "down")]]
assert [r["monitor"].name for r in problems_first(rows)] == [
"able", "Zed", "gamma", "delta", "alpha", "beta"]
def _row(m, state, latest, uptime=None, tls_days=None):
return {"monitor": m, "state": state, "latest": latest, "uptime_pct": uptime, "tls_days": tls_days,
"heartbeat": [{"state": "up" if latest.is_up else "down", "title": "t"}] if latest else []}
async def test_the_list_counts_then_shows_problems_first(app):
data = problems_first([
_row(_mon("web", mid="m1"), "up", _check(ms=30), 100.0, tls_days=2.0),
_row(_mon("api", mid="m2"), "down", _check(False), 90.0),
_row(_mon("nas", "icmp", mid="m3", enabled=False), "paused", None),
])
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")
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="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)
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=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
async def test_the_monitor_page_says_since_when_and_why(app):
m = _mon("shop <api>", mid="m9")
m.config = {"method": "GET", "expected_status": 200, "headers": {"Authorization": "s3cr3t-token"}, "verify_ssl": True}
down = _check(False, ms=None)
async with app.test_request_context("/monitors/m9"):
session["user_role"] = "admin"
page = await render_template(
"monitors/detail.html", monitor=m, host=None, latest=down, failures=[down], state="down",
since=NOW - timedelta(minutes=12), since_at_least=False, tls_days=None,
uptime={"24h": 93.0, "7d": None, "30d": 99.5},
heartbeat=[{"state": "up", "title": "a"}, {"state": "down", "title": "b"}])
assert '<a href="/monitors/">Monitors</a>' in page and "shop &lt;api&gt;" in page
assert "since <time" in page and ">12m ago</time>" in page
assert "503 Service Unavailable" in page
assert 'class="stat-tile stat-crit"' in page and "93.00%" in page
assert "Authorization" in page and "s3cr3t-token" not in page # header names, never their values
assert 'aria-label="Delete shop &lt;api&gt;"' in page
assert not hex_colours(page), hex_colours(page)
async def test_adding_is_its_own_page_and_keeps_what_was_typed(app):
async with app.test_request_context("/monitors/new"):
session["user_role"] = "operator"
page = await render_template(
"monitors/form.html", monitor=None, mtype="tcp", config={"port": "22"}, types=[],
values={"name": "db", "target": "10.0.0.9", "host_id": "", "check_interval_seconds": "30"},
error="port must be between 1 and 65535, got 0.")
assert '<a href="/monitors/">Monitors</a>' in page and 'action="/monitors/add"' in page
assert '<p class="field-error" role="alert">port must be between 1 and 65535, got 0.</p>' in page
assert 'value="db"' in page and 'value="10.0.0.9"' in page and 'value="30"' in page
+1 -1
View File
@@ -22,7 +22,7 @@ _USED = re.compile(r"var\(\s*(--[\w-]+)")
# string -- but not an HTML attribute's value (href="#abc" is a link to an id).
_HEX = re.compile(r"""(?:[:(,]\s*|(?<![\w-]=)['"])#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})(?![0-9a-zA-Z_-])""")
HEX_CEILING = 58 # 75 at #5092, 58 once the share view used steward.css (#5095); only lower it
HEX_CEILING = 56 # 75 at #5092, 58 at #5095, 56 with the Monitors list on tokens (#5121); only lower it
def _sheets():