"""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("/") @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") @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("//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("//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("//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, )