MCP-4 of milestone 461 "MCP server: Steward as an LLM-inspectable, LLM-operable hub". Twelve read-only viewer tools: - get_overview: what is wrong now, ranked critical-then-oldest. Failing monitors with their error and when the run of failures began, firing, pending and still-breaching acknowledged alerts, TLS certificates expiring within 14 days, agents that stopped reporting, hosts over the configured CPU/memory/disk thresholds (Settings > Thresholds), failed Ansible runs in the last 24h, and findings from enabled plugins through a new optional `mcp_overview(db)` plugin hook. Each finding names the call that shows its evidence. - list_hosts, get_host, get_host_metrics - list_monitors, get_monitor (HTTP header values and URL passwords are redacted) - list_alerts, get_alert_history, list_alert_rules, list_maintenance_windows - query_metrics: the whole metric store, narrowed one argument at a time (sources > resources > latest values > a series summary) - list_audit_events Shared shaping in steward/mcp/tools/_shape.py: UTC ISO times with age_s, bounded lists that say when and how they were cut, series as min/avg/max/p95/last plus a ≤60-point series, lookup by id or case-insensitive name with a did-you-mean, and a range argument that refuses an unknown key instead of reading it as 24h. New core reads in steward/core/metric_series.py (sources and resources via loose index scans, latest values via DISTINCT ON, series across the raw/hourly-rollup seam). monitors/service.py gains monitor_states, down_since and recent_failures; agent_overview_by_host now carries the fullest disk and the last sample time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
143 lines
5.5 KiB
Python
143 lines
5.5 KiB
Python
"""Monitor tools: every uptime check, and one check's failures in depth."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
|
|
from sqlalchemy import select
|
|
|
|
from steward.models.hosts import Host
|
|
from steward.models.monitors import MONITOR_TYPE_VALUES, Monitor
|
|
from steward.models.users import UserRole
|
|
from steward.monitors import service as monitor_svc
|
|
|
|
from ._shape import (
|
|
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
|
|
)
|
|
|
|
_STATES = ("up", "down", "pending", "disabled")
|
|
|
|
|
|
def _state(m: Monitor, latest) -> str:
|
|
if not m.enabled:
|
|
return "disabled"
|
|
if latest is None:
|
|
return "pending"
|
|
return "up" if latest.is_up else "down"
|
|
|
|
|
|
def _tls_days(latest) -> float | None:
|
|
if latest is None or latest.tls_expires_at is None:
|
|
return None
|
|
return rnd((latest.tls_expires_at - now_utc()).total_seconds() / 86400.0, 1)
|
|
|
|
|
|
def _row(m: Monitor, st: dict, host_names: dict[str, str]) -> dict:
|
|
latest = st["latest"]
|
|
return {
|
|
"monitor_id": m.id,
|
|
"monitor_name": m.name,
|
|
"type": m.type,
|
|
"target": redact_url(m.target),
|
|
"host_id": m.host_id,
|
|
"host_name": host_names.get(m.host_id) if m.host_id else None,
|
|
"state": _state(m, latest),
|
|
"down_since": iso(st["down_since"]),
|
|
"down_for_s": age_s(st["down_since"]),
|
|
"last_checked_at": iso(latest.checked_at) if latest else None,
|
|
"response_ms": rnd(latest.response_ms) if latest else None,
|
|
"error": latest.error_msg if latest is not None and not latest.is_up else None,
|
|
"uptime_pct": st["uptime"] or {"24h": None, "7d": None, "30d": None},
|
|
"tls_expires_in_days": _tls_days(latest),
|
|
}
|
|
|
|
|
|
async def _load(db):
|
|
monitors = list((await db.execute(select(Monitor).order_by(Monitor.name))).scalars())
|
|
host_names = dict((await db.execute(select(Host.id, Host.name))).all())
|
|
return monitors, host_names
|
|
|
|
|
|
async def list_monitors(state: str = "", type: str = "", limit: int = 100) -> dict:
|
|
"""Every uptime monitor (ping, TCP, DNS, HTTP) with its current state.
|
|
|
|
state: "down", "up", "pending" (never checked yet) or "disabled"; empty for all.
|
|
type: "icmp", "tcp", "dns" or "http"; empty for all.
|
|
limit: default 100, max 500.
|
|
|
|
Down monitors come first, each with `down_since` (when its current run of
|
|
failures began) and the latest `error`. `uptime_pct` covers 24h/7d/30d;
|
|
`tls_expires_in_days` is set for HTTPS checks. get_monitor shows one
|
|
monitor's configuration and recent failures.
|
|
"""
|
|
if state and state not in _STATES:
|
|
raise ValueError(f"state {state!r} is not one of {', '.join(_STATES)}.")
|
|
if type and type not in MONITOR_TYPE_VALUES:
|
|
raise ValueError(f"type {type!r} is not one of {', '.join(MONITOR_TYPE_VALUES)}.")
|
|
limit = clamp_limit(limit, 100, 500)
|
|
async with db_session() as db:
|
|
monitors, host_names = await _load(db)
|
|
if type:
|
|
monitors = [m for m in monitors if m.type == type]
|
|
states = await monitor_svc.monitor_states(db, monitors)
|
|
rows = [_row(m, states[m.id], host_names) for m in monitors]
|
|
if state:
|
|
rows = [r for r in rows if r["state"] == state]
|
|
order = {"down": 0, "pending": 1, "up": 2, "disabled": 3}
|
|
rows.sort(key=lambda r: (order[r["state"]], r["monitor_name"].lower()))
|
|
counts = {s: 0 for s in _STATES}
|
|
for r in rows:
|
|
counts[r["state"]] += 1
|
|
return {"counts": counts, **bounded(rows, limit, key="monitors",
|
|
more="Pass a larger `limit`, or filter by `state` or `type`.")}
|
|
|
|
|
|
def _config(m: Monitor) -> dict:
|
|
try:
|
|
cfg = json.loads(m.config_json or "{}")
|
|
except ValueError:
|
|
return {}
|
|
if isinstance(cfg.get("headers"), dict):
|
|
# Header values are often credentials (Authorization, API keys).
|
|
cfg["headers"] = {k: "<redacted>" for k in cfg["headers"]}
|
|
return cfg
|
|
|
|
|
|
async def get_monitor(monitor: str, failures: int = 10) -> dict:
|
|
"""One monitor in depth: configuration, current state, uptime and recent failures.
|
|
|
|
monitor: the monitor's name (case-insensitive) or id, as list_monitors shows.
|
|
failures: how many of its latest failed checks to return (default 10, max 50),
|
|
newest first, each with its error message.
|
|
|
|
`heartbeat` is its last 30 checks oldest-first as a string, "+" up and "-"
|
|
down, to show a flapping check at a glance. Header values in an HTTP
|
|
monitor's configuration are redacted.
|
|
"""
|
|
failures = clamp_limit(failures, 10, 50)
|
|
async with db_session() as db:
|
|
monitors, host_names = await _load(db)
|
|
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
|
|
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
|
|
failed = await monitor_svc.recent_failures(db, m.id, failures)
|
|
return {
|
|
**_row(m, st, host_names),
|
|
"check_interval_s": m.check_interval_seconds or None,
|
|
"config": _config(m),
|
|
"heartbeat": "".join("+" if r.is_up else "-" for r in st["recent"]),
|
|
"recent_failures": [
|
|
{
|
|
"checked_at": iso(r.checked_at),
|
|
"age_s": age_s(r.checked_at),
|
|
"error": r.error_msg,
|
|
"status_code": r.status_code,
|
|
"response_ms": rnd(r.response_ms),
|
|
}
|
|
for r in failed
|
|
],
|
|
}
|
|
|
|
|
|
def register(register_tool) -> None:
|
|
register_tool(list_monitors, role=UserRole.viewer, read_only=True)
|
|
register_tool(get_monitor, role=UserRole.viewer, read_only=True)
|