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>
72 lines
3.5 KiB
Python
72 lines
3.5 KiB
Python
"""Generic metric access: anything recorded in Steward's metric store."""
|
|
from __future__ import annotations
|
|
|
|
from steward.core import metric_series
|
|
from steward.models.users import UserRole
|
|
|
|
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback, rnd, summarize
|
|
|
|
|
|
async def query_metrics(source_module: str = "", resource: str = "", metric: str = "",
|
|
range: str = "24h", limit: int = 200) -> dict:
|
|
"""Read Steward's metric store, narrowing one argument at a time.
|
|
|
|
Every plugin and monitor type records into one store keyed by
|
|
(source_module, resource, metric): host_agent, docker, traefik, unifi,
|
|
snmp, the monitor types (icmp/tcp/dns/http) and Steward's own self-monitor.
|
|
|
|
- No arguments: the source modules that have data.
|
|
- source_module only: its resources (hosts, routers, containers, devices).
|
|
A host agent's sub-resources are named "<host>:<part>", e.g. "nas:/".
|
|
- source_module + resource: the latest value of each metric it reports.
|
|
- all three: that metric over `range` ("1h", "6h", "24h" default, "7d",
|
|
"30d") as min/avg/max/p95/last plus a ≤60-point [time, value] series.
|
|
|
|
limit bounds the resource list (default 200, max 1000).
|
|
"""
|
|
limit = clamp_limit(limit, 200, 1000)
|
|
async with db_session() as db:
|
|
if not source_module:
|
|
return {"source_modules": await metric_series.sources(db),
|
|
"next": "Pass one as `source_module` to list its resources."}
|
|
|
|
if not resource:
|
|
names = await metric_series.resources(db, source_module)
|
|
if not names:
|
|
known = await metric_series.sources(db)
|
|
raise ValueError(f"no metrics recorded for source_module {source_module!r}. "
|
|
f"Sources with data: {', '.join(known) or 'none'}.")
|
|
return {"source_module": source_module,
|
|
**bounded(names, limit, key="resources",
|
|
more="Pass a larger `limit` (max 1000)."),
|
|
"next": "Pass one as `resource` to see its metrics."}
|
|
|
|
if not metric:
|
|
latest = await metric_series.latest_values(db, source_module, resource)
|
|
if not latest:
|
|
raise ValueError(f"no metrics for resource {resource!r} in {source_module!r}; "
|
|
"call query_metrics with only source_module to list its resources.")
|
|
return {
|
|
"source_module": source_module, "resource": resource,
|
|
"metrics": [
|
|
{"metric": s.metric_name, "last": rnd(s.value),
|
|
"last_at": iso(s.recorded_at), "age_s": age_s(s.recorded_at)}
|
|
for s in latest
|
|
],
|
|
"next": "Pass one as `metric` for its history.",
|
|
}
|
|
|
|
since, key = lookback(range)
|
|
pts = await metric_series.points(db, source_module, resource, metric, since)
|
|
if not pts:
|
|
names = [s.metric_name for s in await metric_series.latest_values(db, source_module, resource)]
|
|
if metric not in names:
|
|
raise ValueError(f"{resource!r} in {source_module!r} has no metric {metric!r}. "
|
|
f"It reports: {', '.join(names) or 'nothing'}.")
|
|
return {"source_module": source_module, "resource": resource, "metric": metric,
|
|
"range": key, **summarize(pts)}
|
|
|
|
|
|
def register(register_tool) -> None:
|
|
register_tool(query_metrics, role=UserRole.viewer, read_only=True)
|