"""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 sqlalchemy import select 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.status import _last_n_results, _uptime_by_monitor, _heartbeat_title from steward.core.time_range import parse_range, DEFAULT_RANGE 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 async def _rows_data(range_key: str): """Per-monitor display data for the list / widget.""" since, range_key = parse_range(range_key) async with current_app.db_sessionmaker() as db: monitors = list((await db.execute( select(Monitor).order_by(Monitor.name) )).scalars()) ids = [m.id for m in monitors] recent = await _last_n_results(db, ids) uptime = await _uptime_by_monitor(db, ids) # Map the selected range to the uptime bucket we surface in the list. bucket = {"24h": "24h", "7d": "7d", "30d": "30d"}.get(range_key, "24h") now = datetime.now(timezone.utc) data = [] for m in monitors: rows = recent.get(m.id, []) latest = rows[-1] if rows else None 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, "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), "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 @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(): data, up, down, pending, range_key = await _rows_data( 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") data, up, down, pending, _ = await _rows_data(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, )