MCP-3 of milestone 461 "MCP server: Steward as an LLM-inspectable, LLM-operable hub". The /mcp tools run outside Quart's request pipeline, so the queries they share with the pages now live in plain functions that take an open session: - steward/hosts/service.py: uptime, status, monitors, agent metrics, agent overview (stale threshold passed in, not read from current_app), CPU sparks. - plugins/docker/service.py: bucket, container_history, host_map, list_containers, container_events, query_logs. - steward/alerts/service.py: active_alerts (also used by core/reports), list_rules, list_maintenance_windows. - steward/monitors/service.py: monitor_rows (was routes._rows_data). Routes are repointed and render the same data. One behavior fix rides along: the container-log search now escapes % and _ so they match literally instead of acting as LIKE wildcards. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
6.7 KiB
Python
181 lines
6.7 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 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("/<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")
|
|
|
|
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,
|
|
)
|