"""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.models.users import UserRole from steward.models.monitors import Monitor, MonitorType, MONITOR_TYPE_VALUES from steward.core.time_range import DEFAULT_RANGE 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), types=list(MonitorType), ) @monitors_bp.get("/rows") @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)) return await render_template( "monitors/rows.html", monitor_data=data, up=up, down=down, pending=pending, 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 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(): db.add(monitor) # A host-scoped add returns to that host's hub; otherwise the monitors page. if host_id: return redirect(url_for("hosts.host_detail", host_id=host_id)) return redirect(url_for("monitors.index")) @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/edit.html", monitor=m, config=m.config, types=list(MonitorType), ) @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)) except ValueError as exc: return str(exc), 400 for k, v in fields.items(): setattr(m, k, v) return redirect(url_for("monitors.index")) @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) return redirect(request.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, up, down, pending, _ = await monitor_svc.monitor_rows(db, DEFAULT_RANGE) 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, )