MCP-7 #4971 "Monitoring-config write tools: monitors, alert rules, acks, maintenance". The validation the forms did inline (or not at all) moves to the services, and both the routes and the tools call it, so bad input is refused the same way with the same reason: - monitors/service: monitor_fields, build_config, normalise_target. Port 1-65535, status 100-599, timeout 1-60, method GET/HEAD/POST, headers an object of strings, a key the type does not take is named. The forms now answer 400 with the reason instead of redirecting silently or a 500. - alerts/service: METRIC_CATALOG and SOURCE_MODULES, resources_for, metrics_for, rule_fields (source, resource and metric from the same choices the rule form offers), add_rule, acknowledge, window_fields. Tools: create_monitor, update_monitor (config merged over the stored one; a rename warns about rules still watching the old name), set_monitor_enabled, create_alert_rule, update_alert_rule, acknowledge_alert, create_maintenance_window (duration or end), end_maintenance_window (ends one in progress, cancels an upcoming one) — operator. delete_monitor, delete_alert_rule — admin. get_alert_rule_options — viewer. Every write is audited as "<user> via MCP". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
169 lines
6.3 KiB
Python
169 lines
6.3 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 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("/<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.
|
|
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("/<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")
|
|
|
|
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,
|
|
)
|