Files
FabledSteward/steward/monitors/routes.py
T
bvandeusenandClaude Opus 5.5 e595bd922a
CI / lint (push) Successful in 3s
CI / unit (push) Failing after 1m23s
CI / integration (push) Successful in 1m12s
CI / publish (push) Skipped
feat(monitors): problems first, a page per monitor, adding on its own page (#5121)
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>
2026-10-06 17:54:30 -04:00

235 lines
9.5 KiB
Python

"""Unified monitors management surface (/monitors) — ping, DNS, HTTP.
Replaces the former /ping, /dns and /plugins/http pages. One list, one
type-aware add/edit form, one dashboard widget. A monitor may be linked to a
Host (host_id) or watch a free-standing custom destination (host_id = NULL).
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from quart import Blueprint, current_app, render_template, request, redirect, url_for
from steward.auth.middleware import require_role
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")
def _form_opts(form, mtype: str) -> dict:
"""The submitted fields of `mtype`'s config, for monitor_svc.build_config.
An unchecked checkbox is absent from the form, so it reads as False."""
if mtype == MonitorType.tcp.value:
return {"port": form.get("port")}
if mtype == MonitorType.dns.value:
return {"expected_ip": form.get("expected_ip")}
if mtype == MonitorType.http.value:
return {
"method": form.get("method"),
"expected_status": form.get("expected_status"),
"content_match": form.get("content_match"),
"headers": form.get("headers_json"),
"timeout_seconds": form.get("timeout_seconds"),
"follow_redirects": "follow_redirects" in form,
"verify_ssl": "verify_ssl" in form,
}
return {}
@monitors_bp.get("/")
@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),
)
@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,
)
@monitors_bp.post("/add")
@require_role(UserRole.operator)
async def add_monitor():
form = await request.form
mtype = form.get("type", "").strip()
try:
fields = monitor_svc.monitor_fields(
mtype, target=form.get("target", ""), name=form.get("name", ""),
check_interval_seconds=form.get("check_interval_seconds"),
opts=_form_opts(form, mtype))
except ValueError as exc:
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,
created_at=datetime.now(timezone.utc), **fields)
async with current_app.db_sessionmaker() as db:
async with db.begin():
try:
await monitor_svc.check_name_free(db, monitor.type, monitor.name)
except ValueError as exc:
return await _new_form(form, mtype, str(exc)), 400
db.add(monitor)
# 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.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")
@require_role(UserRole.operator)
async def edit_form(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"))
return await render_template(
"monitors/form.html", monitor=m, values=None, mtype=m.type, config=m.config,
types=list(MonitorType), error=None,
)
@monitors_bp.post("/<monitor_id>/edit")
@require_role(UserRole.operator)
async def edit_monitor(monitor_id: str):
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
m = await db.get(Monitor, monitor_id)
if not m:
return redirect(url_for("monitors.index"))
# Type is immutable on edit (changes the meaning of config/target);
# to switch type, delete and re-add.
try:
fields = monitor_svc.monitor_fields(
m.type, target=form.get("target", m.target), name=form.get("name", ""),
check_interval_seconds=form.get("check_interval_seconds"),
opts=_form_opts(form, m.type))
if fields["name"] != m.name:
await monitor_svc.check_name_free(db, m.type, fields["name"], exclude_id=m.id)
except ValueError as exc:
return str(exc), 400
old_name = m.name
for k, v in fields.items():
setattr(m, k, v)
# Its rules, windows and history go by (type, name): move them with it.
moved = await renames.carry_rename(db, ((m.type, None),), old_name, m.name)
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.detail", monitor_id=m.id))
@monitors_bp.post("/<monitor_id>/toggle")
@require_role(UserRole.operator)
async def toggle_monitor(monitor_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
m = await db.get(Monitor, monitor_id)
if m:
m.enabled = not m.enabled
return redirect(request.referrer or url_for("monitors.index"))
@monitors_bp.post("/<monitor_id>/delete")
@require_role(UserRole.operator)
async def delete_monitor(monitor_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
m = await db.get(Monitor, monitor_id)
if m:
await db.delete(m)
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")
@require_role(UserRole.viewer)
async def widget():
type_filter = request.args.get("type_filter", "all")
show_down_only = request.args.get("show_down_only", "no") == "yes"
limit = max(1, min(50, int(request.args.get("limit", 10) or 10)))
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", {}))
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:
data = [d for d in data if not (d["latest"] and d["latest"].is_up)]
return await render_template(
"monitors/widget.html",
monitor_data=data[:limit], up=up, down=down, pending=pending,
show_down_only=show_down_only, widget_id=widget_id,
)