"""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 json 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 _build_config(form, mtype: str) -> dict: """Assemble the type-specific config dict from submitted form fields.""" if mtype == MonitorType.tcp.value: return {"port": int(form.get("port") or 80)} if mtype == MonitorType.dns.value: return {"expected_ip": (form.get("expected_ip", "").strip() or None)} if mtype == MonitorType.http.value: try: headers = json.loads(form.get("headers_json") or "{}") except (ValueError, TypeError): headers = {} return { "method": form.get("method", "GET").upper(), "expected_status": int(form.get("expected_status", 200) or 200), "content_match": form.get("content_match", "").strip(), "headers": headers, "timeout_seconds": int(form.get("timeout_seconds", 10) or 10), "follow_redirects": "follow_redirects" in form, "verify_ssl": "verify_ssl" in form, } return {} # icmp def _normalise_target(mtype: str, target: str) -> str: """For http, default a bare host to https://; others pass through.""" target = target.strip() if mtype == MonitorType.http.value and target and not target.startswith(("http://", "https://")): return "https://" + target return target @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() target = _normalise_target(mtype, form.get("target", "")) if mtype not in MONITOR_TYPE_VALUES or not target: return redirect(url_for("monitors.index")) host_id = form.get("host_id", "").strip() or None monitor = Monitor( id=str(uuid.uuid4()), name=form.get("name", "").strip() or target, type=mtype, target=target, host_id=host_id, config_json=json.dumps(_build_config(form, mtype)), enabled=True, check_interval_seconds=int(form.get("check_interval_seconds", 0) or 0), created_at=datetime.now(timezone.utc), ) 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. m.name = form.get("name", "").strip() or m.target m.target = _normalise_target(m.type, form.get("target", m.target)) m.config_json = json.dumps(_build_config(form, m.type)) m.check_interval_seconds = int(form.get("check_interval_seconds", 0) or 0) 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, )