Files
FabledSteward/steward/monitors/routes.py
T
bvandeusen 35f658b573
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 8s
CI / integration (push) Successful in 2m17s
CI / publish (push) Successful in 1m10s
feat(monitors): unify ping/dns/http into one Monitor entity + custom targets
Collapse the three former check types into a single core `Monitor` entity
with one management surface (/monitors), one result table (monitor_results),
and a single scheduled task. Every type can now watch a free-standing custom
destination (optional host_id) — not just a registered Host.

- models: Monitor + MonitorResult replace PingResult/DnsResult; Host loses its
  ping/dns facet columns (now Monitor rows linked by host_id).
- checks: monitors/{ping,dns,http}.py pure probes + runner.run_monitor
  dispatcher; one monitor_check scheduler with a per-monitor due-filter.
- status: single monitor_status_source replaces the three sources.
- UI: /monitors blueprint (type-aware add/edit/list/widget); host hub shows a
  host's linked monitors + "add monitor for this host"; nav + widget registry
  + alert metric catalog rewired. http plugin folded into core and removed.
- migration 0022 merges the http branch, data-migrates host facets +
  http_monitors + all three result histories, drops the old tables/columns.

Resolves the per-host ping/dns auto-attach issue (#275): monitors are now
explicit, never auto-added to every host.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC
2026-06-18 08:56:13 -04:00

216 lines
8.1 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 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("/<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/edit.html", monitor=m, config=m.config, types=list(MonitorType),
)
@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.
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("/<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)
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,
)