diff --git a/steward/app.py b/steward/app.py index 0915892..2c5baf1 100644 --- a/steward/app.py +++ b/steward/app.py @@ -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 diff --git a/steward/core/formatting.py b/steward/core/formatting.py index 385fb1f..f0747c9 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -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"), } diff --git a/steward/core/status.py b/steward/core/status.py index 5e69c5b..5f22c59 100644 --- a/steward/core/status.py +++ b/steward/core/status.py @@ -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 diff --git a/steward/models/monitors.py b/steward/models/monitors.py index 66bcd75..89713f2 100644 --- a/steward/models/monitors.py +++ b/steward/models/monitors.py @@ -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')" diff --git a/steward/monitors/routes.py b/steward/monitors/routes.py index 544c02a..ed7efd1 100644 --- a/steward/monitors/routes.py +++ b/steward/monitors/routes.py @@ -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("/") +@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("//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("//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: diff --git a/steward/monitors/service.py b/steward/monitors/service.py index f47b4a0..9adc47d 100644 --- a/steward/monitors/service.py +++ b/steward/monitors/service.py @@ -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): diff --git a/steward/static/steward.css b/steward/static/steward.css index d753d8f..c339737 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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:
…
…
. */ +.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; } diff --git a/steward/templates/monitors/detail.html b/steward/templates/monitors/detail.html new file mode 100644 index 0000000..2e7162e --- /dev/null +++ b/steward/templates/monitors/detail.html @@ -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") }} +
{{ csrf_field() }} + {{ icon_button("⏸" if monitor.enabled else "▶", ("Pause " if monitor.enabled else "Resume ") ~ monitor.name, type="submit") }} +
+
{{ csrf_field() }} + {{ icon_button("×", "Delete " ~ monitor.name, type="submit", cls="btn-danger", confirm="Delete monitor " ~ monitor.name ~ "? Its check history goes with it.") }} +
+ {% endif %} +{% endcall %} + +
+ {% 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 %} +
+ +
+

Last checks

+ {% if heartbeat %} +
+ {% for _ in range([30 - heartbeat|length, 0]|max) %}{% endfor %} + {% for h in heartbeat %}{% endfor %} +
+

Newest on the right.{% if latest %} Last checked {{ latest.checked_at|ago }}.{% endif %}

+ {% else %} + {{ empty_state("Not checked yet. The first check runs on the next scheduler tick." if monitor.enabled else "Paused, and never checked.") }} + {% endif %} +
+ +
+

Recent failures

+ {% if failures %} + + {% if monitor.type == "http" %}{% endif %} + + {% for f in failures %} + + + + {% if monitor.type == "http" %}{% endif %} + + {% endfor %} + +
WhenErrorStatus
{{ f.checked_at|ago }}{{ f.error_msg or "Down" }}{{ f.status_code or "—" }}
+ {% else %} + {{ empty_state("No failed checks on record.") }} + {% endif %} +
+ +
+

Settings

+
+
Checked every
+
{{ monitor.check_interval_seconds|duration if monitor.check_interval_seconds else "the global interval" }}
+ {% if host %}
Host
{{ host.name }}
{% endif %} + {% for key, value in monitor.config.items() %} + {% if value not in (none, "", {}) %} +
{{ key|replace("_", " ")|capitalize }}
+
{{ value|join(", ") if value is mapping else ("yes" if value is sameas true else ("no" if value is sameas false else value)) }}
+ {% endif %} + {% endfor %} +
+
+{% endblock %} diff --git a/steward/templates/monitors/edit.html b/steward/templates/monitors/edit.html deleted file mode 100644 index 1fe0ca6..0000000 --- a/steward/templates/monitors/edit.html +++ /dev/null @@ -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 %} -
-

Edit Monitor

-
- - -
-
{{ csrf_field() }} -
-
- - -
-
- - -
-
- - -
-
- - -
- {{ type_fields(config) }} -
- {% if monitor.host_id %} -

- Linked to its host. -

- {% endif %} -
- - Cancel -
-
-
- -{{ toggle_script() }} - -{% endblock %} diff --git a/steward/templates/monitors/form.html b/steward/templates/monitors/form.html new file mode 100644 index 0000000..6f2c403 --- /dev/null +++ b/steward/templates/monitors/form.html @@ -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") }} + +
+ {% if error %}{% endif %} +
{{ csrf_field() }} + {% if not monitor and v.host_id %}{% endif %} +
+
+ + {% if monitor %} + + {% else %} + + {% endif %} +
+
+ + +
+
+ + +
+
+ + +
+ {{ type_fields(config) }} +
+ {% if monitor and monitor.host_id %} +

Linked to its host.

+ {% endif %} +
+ + Cancel +
+
+
+ +{{ toggle_script() }} + +{% endblock %} diff --git a/steward/templates/monitors/index.html b/steward/templates/monitors/index.html index 9876e99..f0ba967 100644 --- a/steward/templates/monitors/index.html +++ b/steward/templates/monitors/index.html @@ -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 %} -
-
-

Monitors

- polling every {{ poll_interval }}s -
+{% call page_header("Monitors", "Checked every " ~ poll_interval ~ "s. Problems first.") %} {% include "_time_range.html" %} + {% if session.user_role in ['operator', 'admin'] %}Add monitor{% endif %} +{% endcall %} + +
+

Loading…

- - -{# ── Add monitor ──────────────────────────────────────────────────────────── #} -
-
Add Monitor
-
{{ csrf_field() }} -
-
- - -
-
- - -
-
- - -
-
- - -
- {{ type_fields() }} -
-
-
-
- -{# ── Live monitor rows ────────────────────────────────────────────────────── #} -
-
-
Loading…
-
-
- -{{ toggle_script() }} - {% endblock %} diff --git a/steward/templates/monitors/rows.html b/steward/templates/monitors/rows.html index cba82af..9baefd5 100644 --- a/steward/templates/monitors/rows.html +++ b/steward/templates/monitors/rows.html @@ -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 %} +
+ {{ 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") }} +
+ {% if monitor_data %} +
+{% set can_edit = session.user_role in ['operator', 'admin'] %} {% for d in monitor_data %} {% set m = d.monitor %} {% set latest = d.latest %} -
-
- {{ m.type | upper }} - {% if m.host_id %} - {{ m.name }} - {% else %} - {{ m.name }} - {% endif %} - {{ m.target }} +
+
+
+ {{ status_dot(d.state) }} + {{ m.name }} +
+ {{ m.type|monitor_type }} · {{ m.target }}
- {% for _ in range([30 - d.heartbeat|length, 0]|max) %} - - {% endfor %} - {% for h in d.heartbeat %} - - {% endfor %} + {% for _ in range([30 - d.heartbeat|length, 0]|max) %}{% endfor %} + {% for h in d.heartbeat %}{% endfor %}
- {% if latest is none %} - — - {% elif not latest.is_up %} - DOWN + {% if d.state in ('down', 'paused', 'pending') %}{{ status_badge(d.state) }} {% elif latest.response_ms is not none %} - - {{ "%.0f"|format(latest.response_ms) }} ms - {% elif m.type == 'dns' and latest.resolved_ip %} - {{ latest.resolved_ip }} - {% else %} - UP - {% endif %} + {{ "%.0f"|format(latest.response_ms) }} ms + {% elif m.type == 'dns' and latest.resolved_ip %}{{ latest.resolved_ip }} + {% else %}{{ status_badge('up') }}{% endif %}
-
- {% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %} - {{ range_key }} +
+ {% if d.uptime_pct is not none %}{{ "%.1f"|format(d.uptime_pct) }}%{% else %}—{% endif %} + {{ range_key }}
{% if d.tls_days is not none %} -
- cert {{ d.tls_days|round|int }}d +
+ {% if d.tls_days < 0 %}cert expired + {% else %}cert {{ d.tls_days|round|int }}d{% endif %}
{% endif %} - {% if session.user_role in ['operator', 'admin'] %} -
- Edit -
{{ csrf_field() }} - + {% if can_edit %} +
+ {{ icon_button("✎", "Edit " ~ m.name, href="/monitors/" ~ m.id ~ "/edit") }} + {{ csrf_field() }} + {{ icon_button("⏸" if m.enabled else "▶", ("Pause " if m.enabled else "Resume ") ~ m.name, type="submit") }} -
{{ csrf_field() }} - + {{ csrf_field() }} + {{ icon_button("×", "Delete " ~ m.name, type="submit", confirm="Delete monitor " ~ m.name ~ "?") }}
{% endif %}
{% endfor %} +
{% else %} -

- No monitors yet. Add one above, or attach checks to a host from the - Hosts page. -

+
{{ 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") }}
{% endif %} diff --git a/steward/templates/monitors/widget.html b/steward/templates/monitors/widget.html index aea1b06..a3f8989 100644 --- a/steward/templates/monitors/widget.html +++ b/steward/templates/monitors/widget.html @@ -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 %}
- No monitors configured. Add monitors → + No monitors configured. Add a monitor →
{% else %} diff --git a/steward/templates/status/rows.html b/steward/templates/status/rows.html index 86dc5a8..63921f0 100644 --- a/steward/templates/status/rows.html +++ b/steward/templates/status/rows.html @@ -41,7 +41,7 @@
{% if not entries %} -
{{ empty_state("No monitors yet. Add a host (ping or DNS) or an HTTP monitor to fill the board.", "Add a monitor", "/monitors/") }}
+
{{ empty_state("No monitors yet. Add a host (ping or DNS) or an HTTP monitor to fill the board.", "Add a monitor", "/monitors/new") }}
{% else %}
{# header row #} diff --git a/tests/core/test_monitors_page.py b/tests/core/test_monitors_page.py new file mode 100644 index 0000000..cf64530 --- /dev/null +++ b/tests/core/test_monitors_page.py @@ -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('Down') < 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 '30 ms' in page + assert '90.0%' in page and '100.0%' in page + assert 'class="tone-crit" title="TLS certificate expires in 2 days">cert 2d' in page + assert 'Down' in page and 'Paused' 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 ", 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 'Monitors' in page and "shop <api>" in page + assert "since 12m ago" 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 <api>"' 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 'Monitors' in page and 'action="/monitors/add"' in page + assert '' in page + assert 'value="db"' in page and 'value="10.0.0.9"' in page and 'value="30"' in page diff --git a/tests/test_style_tokens.py b/tests/test_style_tokens.py index d72b4e7..cc276c4 100644 --- a/tests/test_style_tokens.py +++ b/tests/test_style_tokens.py @@ -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*|(?