feat(mcp): SNMP devices and Prometheus targets: their state, readings and series (#5840)
The snmp plugin registers list_snmp_devices and get_snmp_device; the prometheus plugin registers list_prometheus_targets and get_prometheus_target. Both are viewer-level, read what the poller and scraper stored, and exist only while their plugin is enabled. get_overview names a device or target whose last poll or scrape failed; stale, pending and off are left out. A device's community and v3 keys and a target's token are never returned, and a password in a target URL is masked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,3 +39,13 @@ def get_audit_entities() -> dict:
|
||||
"""Audit rows about a device name it and link to its page."""
|
||||
from .models import SnmpDevice
|
||||
return {"snmp_device": (SnmpDevice, "/plugins/snmp/devices/{}")}
|
||||
|
||||
|
||||
def register_mcp_tools(register) -> None:
|
||||
from .mcp_tools import register_tools
|
||||
register_tools(register)
|
||||
|
||||
|
||||
async def mcp_overview(db) -> list[dict]:
|
||||
from .mcp_tools import mcp_overview as findings
|
||||
return await findings(db)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""The snmp plugin's MCP tools and its get_overview findings (#5840).
|
||||
|
||||
Registered through register_mcp_tools() in __init__.py, so they exist only
|
||||
while the plugin is enabled. They read what the poller stored; nothing asks a
|
||||
device anything, and a device's community and v3 keys are never returned.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.core.metric_series import points
|
||||
from steward.mcp._context import mcp_app
|
||||
from steward.mcp.tools._shape import age_s, db_session, iso, lookback, now_utc, pick, summarize
|
||||
from steward.mcp.tools.overview import finding
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from .models import SnmpDevice
|
||||
from .readings import stored_unit
|
||||
from .routes import _latest, shown_readings
|
||||
from .service import device_state, readings_for
|
||||
|
||||
REACHES = ("down", "stale", "pending", "up")
|
||||
_ORDER = {r: i for i, r in enumerate(REACHES)}
|
||||
|
||||
|
||||
def _poll_seconds() -> int:
|
||||
return int(mcp_app().config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60))
|
||||
|
||||
|
||||
def _device_view(d: SnmpDevice, values: dict, now, poll: int) -> dict:
|
||||
state = device_state(d, poll, now)
|
||||
names = {r.key: r.name for r in readings_for(d)}
|
||||
return {"device_id": d.id, "name": d.name, "address": d.address, "port": d.port, "version": d.version,
|
||||
"host_id": d.host_id, "reach": state.reach, "says": state.says,
|
||||
"polled_at": iso(d.last_polled_at), "ok_at": iso(d.last_ok_at), "ok_age_s": age_s(d.last_ok_at, now),
|
||||
"readings": [{"key": r["key"], "name": r["name"], "value": r["text"], "counter": r["counter"]}
|
||||
for r in shown_readings(d, values)],
|
||||
"missing": [names.get(k, k) for k in d.missing or []]}
|
||||
|
||||
|
||||
async def list_snmp_devices(reach: str = "") -> dict:
|
||||
"""The devices Steward polls over SNMP (switches, UPSes, printers, NAS
|
||||
boxes), not answering first.
|
||||
|
||||
reach: "down" (the last poll failed; `says` is why), "stale" (not polled
|
||||
recently: the plugin may be off), "pending" (never polled), "up"; empty
|
||||
for all.
|
||||
|
||||
Each device gives its newest readings in their units (counters as
|
||||
rates), and `missing`: readings it is configured for that the device
|
||||
didn't return. get_snmp_device gives a device's history.
|
||||
"""
|
||||
if reach and reach not in REACHES:
|
||||
raise ValueError(f"reach must be one of {', '.join(REACHES)}, or empty for all.")
|
||||
now, poll = now_utc(), _poll_seconds()
|
||||
async with db_session() as db:
|
||||
devices = list((await db.execute(select(SnmpDevice).order_by(SnmpDevice.name))).scalars())
|
||||
values = await _latest(db, [d.name for d in devices])
|
||||
rows = sorted((_device_view(d, values.get(d.name, {}), now, poll) for d in devices),
|
||||
key=lambda v: (_ORDER[v["reach"]], v["name"].lower()))
|
||||
return {"devices": [r for r in rows if not reach or r["reach"] == reach], "total": len(rows)}
|
||||
|
||||
|
||||
async def get_snmp_device(device: str, range: str = "24h") -> dict:
|
||||
"""One SNMP device: its state, newest readings, and each reading's
|
||||
history over `range` (min/avg/max/last and ≤60 points, in the unit the
|
||||
reading is stored in: `unit`).
|
||||
|
||||
device: its name or id (list_snmp_devices).
|
||||
range: "1h", "6h", "24h" (default), "7d" or "30d".
|
||||
"""
|
||||
since, key = lookback(range)
|
||||
now, poll = now_utc(), _poll_seconds()
|
||||
async with db_session() as db:
|
||||
devices = list((await db.execute(select(SnmpDevice))).scalars())
|
||||
d = pick(device, devices, kind="SNMP device", list_tool="list_snmp_devices")
|
||||
values = (await _latest(db, [d.name])).get(d.name, {})
|
||||
history = {r.key: (stored_unit(r), await points(db, "snmp", d.name, r.key, since))
|
||||
for r in readings_for(d)}
|
||||
out = _device_view(d, values, now, poll)
|
||||
out["range"] = key
|
||||
out["history"] = {k: {"unit": unit, **summarize(pts)} for k, (unit, pts) in history.items()}
|
||||
return out
|
||||
|
||||
|
||||
def device_findings(views: list[dict]) -> list[dict]:
|
||||
"""A device whose last poll failed. A stale one is the plugin or poller,
|
||||
not the device, so it is left out."""
|
||||
return [finding("warning", "snmp_device_down", v["name"],
|
||||
f"SNMP device {v['name']} ({v['address']}) isn't answering: {v['says']}",
|
||||
since=v["_ok_at"], ids={"device_id": v["device_id"]},
|
||||
next=f'get_snmp_device(device="{v["name"]}")')
|
||||
for v in views if v["reach"] == "down"]
|
||||
|
||||
|
||||
async def mcp_overview(db) -> list[dict]:
|
||||
now, poll = now_utc(), _poll_seconds()
|
||||
devices = list((await db.execute(select(SnmpDevice))).scalars())
|
||||
views = [_device_view(d, {}, now, poll) | {"_ok_at": d.last_ok_at} for d in devices]
|
||||
return device_findings(views)
|
||||
|
||||
|
||||
def register_tools(register) -> None:
|
||||
for fn in (list_snmp_devices, get_snmp_device):
|
||||
register(fn, role=UserRole.viewer, read_only=True)
|
||||
Reference in New Issue
Block a user