# plugins/snmp/routes.py from __future__ import annotations from datetime import datetime, timedelta, timezone from quart import Blueprint, current_app, render_template, request from sqlalchemy import select from steward.auth.middleware import require_role from steward.models.users import UserRole from steward.core.formatting import human_bytes, human_duration from steward.core.metric_series import latest_by_resource from steward.models.metrics import PluginMetric snmp_bp = Blueprint("snmp", __name__, template_folder="templates") async def _latest_readings(db, device_names: list[str]) -> dict[str, dict]: """{device_name: {"readings": {label: latest_value}, "at": newest reading's time}} for the devices that have any readings.""" latest = await latest_by_resource(db, "snmp", device_names) return {name: {"readings": {m: r.value for m, r in metrics.items()}, "at": max(r.recorded_at for r in metrics.values())} for name, metrics in latest.items() if metrics} # A device whose newest reading is older than this many poll intervals is not # shown as reachable: the poller has stopped getting answers from it. STALE_POLLS = 3 def _device_state(entry: dict | None, poll_seconds: int, now: datetime) -> dict: """What the page says about a device's readings: {"readings", "at", "reach"}, reach being "up" (answered within STALE_POLLS polls), "stale" (has readings, but none recent) or "pending" (never answered).""" if not entry: return {"readings": {}, "at": None, "reach": "pending"} fresh = (now - entry["at"]).total_seconds() <= STALE_POLLS * max(int(poll_seconds), 1) return {**entry, "reach": "up" if fresh else "stale"} def _poll_seconds() -> int: return int(current_app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60)) async def _history(db, device_name: str, hours: int = 24) -> dict[str, list]: """Return {label: [(recorded_at, value), ...]} for a single device.""" since = datetime.now(timezone.utc) - timedelta(hours=hours) result = await db.execute( select(PluginMetric) .where( PluginMetric.source_module == "snmp", PluginMetric.resource_name == device_name, PluginMetric.recorded_at >= since, ) .order_by(PluginMetric.recorded_at) ) rows = result.scalars().all() out: dict[str, list] = {} for row in rows: out.setdefault(row.metric_name, []).append( (row.recorded_at.isoformat(), row.value) ) return out @snmp_bp.get("/") @require_role(UserRole.viewer) async def index(): devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", []) device_names = [d.get("name") or d.get("host", "?") for d in devices_cfg] async with current_app.db_sessionmaker() as db: latest = await _latest_readings(db, device_names) now = datetime.now(timezone.utc) # Merge config + latest readings for the template devices = [] for cfg in devices_cfg: name = cfg.get("name") or cfg.get("host", "?") devices.append({ "name": name, "host": cfg.get("host", ""), "oids": cfg.get("oids", []), **_device_state(latest.get(name), _poll_seconds(), now), }) poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60) return await render_template("snmp/index.html", devices=devices, poll_interval=poll_interval) def _device_binds_to_host(d: dict, keys: set[str], host_id: str) -> bool: """Does config device `d` belong on the host identified by `keys` (lowercased {address, name}) / `host_id`? A device may declare an **explicit** binding that decouples "which Steward host this belongs to" from "where to poll" (the `host` field): • `host_id` — the Steward Host UUID (exact match), the explicit link. • `steward_host` — a friendly bind by Host name or address (case-insensitive). An explicit binding is **exclusive**: a device bound to host A must not also match host B by a coincidental poll-target string. Only when no explicit binding is present do we fall back to the implicit `host`-string match (backward compatible with configs that only set `host`). """ explicit_id = str(d.get("host_id", "")).strip() explicit_name = str(d.get("steward_host", "")).strip().lower() if explicit_id or explicit_name: if explicit_id and host_id and explicit_id == host_id: return True return bool(explicit_name and explicit_name in keys) return str(d.get("host", "")).strip().lower() in keys def _devices_for_host(devices_cfg: list, address: str | None, name: str | None, host_id: str | None = None) -> list[dict]: """SNMP devices that map onto a Steward host's page. Prefers an explicit per-device binding (`host_id` / `steward_host`); otherwise falls back to matching the device's `host` (poll target) against the host's address or name (case-insensitive). See `_device_binds_to_host` for the precedence.""" keys = {(address or "").strip().lower(), (name or "").strip().lower()} keys.discard("") hid = (host_id or "").strip() return [ d for d in devices_cfg if isinstance(d, dict) and _device_binds_to_host(d, keys, hid) ] @snmp_bp.get("/host/") @require_role(UserRole.viewer) async def host_panel(host_id: str): """Per-host SNMP fragment for the Hosts hub. Surfaces any configured SNMP device that maps to this host (by address or name) with its latest readings. Renders nothing when no device maps, so hosts without SNMP carry no empty card. """ from steward.models.hosts import Host devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", []) async with current_app.db_sessionmaker() as db: host = (await db.execute( select(Host).where(Host.id == host_id))).scalar_one_or_none() if host is None: return "" matched = _devices_for_host(devices_cfg, host.address, host.name, host.id) if not matched: return "" names = [d.get("name") or d.get("host", "?") for d in matched] latest = await _latest_readings(db, names) now = datetime.now(timezone.utc) devices = [{ "name": d.get("name") or d.get("host", "?"), "host": d.get("host", ""), "oids": d.get("oids", []), **_device_state(latest.get(d.get("name") or d.get("host", "?")), _poll_seconds(), now), "bound": bool(str(d.get("host_id", "")).strip() or str(d.get("steward_host", "")).strip()), } for d in matched] return await render_template("snmp/host_panel.html", devices=devices) # Labels by suffix: how a reading reads ("uptime_centisecs" → uptime 12d 4h). # Counters (octets, packets) only ever grow, so the raw number says little; # a device's key readings prefer the others. _COUNTER_SUFFIXES = ("_octets", "_packets", "_pkts", "_errors", "_discards") def snmp_reading(label: str, value: float) -> tuple[str, str]: """(name, value) as a reader would say them: units taken from the label's suffix, underscores as spaces.""" if label.endswith("_centisecs"): return label[:-len("_centisecs")].replace("_", " "), human_duration(value / 100) if label.endswith("_pct"): return label[:-len("_pct")].replace("_", " "), f"{value:.0f}%" if label.endswith("_dV"): return label[:-len("_dV")].replace("_", " "), f"{value / 10:.1f} V" if label.endswith("_octets"): return label[:-len("_octets")].replace("_", " "), f"{human_bytes(value)} total" return label.replace("_", " "), f"{value:,.0f}" if float(value).is_integer() else f"{value:.4g}" def key_readings(oids: list[dict], readings: dict, limit: int = 2) -> list[tuple[str, str]]: """The first `limit` readings in config order, gauges before counters.""" labels = [o.get("label") or o.get("oid") for o in oids if isinstance(o, dict)] present = [lb for lb in labels if lb in readings] ordered = ([lb for lb in present if not lb.endswith(_COUNTER_SUFFIXES)] + [lb for lb in present if lb.endswith(_COUNTER_SUFFIXES)]) return [snmp_reading(lb, readings[lb]) for lb in ordered[:limit]] _REACH_ORDER = {"stale": 0, "pending": 1, "up": 2} DEVICES_SHOWN = 10 @snmp_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): """The dashboard SNMP widget (#5255), in the widget-shape order: a verdict, devices not answering first with since when, then the rest, each with its status in words and one or two readable readings.""" devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", []) device_names = [d.get("name") or d.get("host", "?") for d in devices_cfg] async with current_app.db_sessionmaker() as db: latest = await _latest_readings(db, device_names) now = datetime.now(timezone.utc) devices = [] for cfg in devices_cfg: name = cfg.get("name") or cfg.get("host", "?") state = _device_state(latest.get(name), _poll_seconds(), now) devices.append({"name": name, **state, "key": key_readings(cfg.get("oids", []), state["readings"])}) devices.sort(key=lambda d: _REACH_ORDER[d["reach"]]) counts = {r: sum(1 for d in devices if d["reach"] == r) for r in _REACH_ORDER} return await render_template("snmp/widget.html", devices=devices[:DEVICES_SHOWN], more=max(0, len(devices) - DEVICES_SHOWN), counts=counts, total=len(devices)) @snmp_bp.get("/device/") @require_role(UserRole.viewer) async def device_detail(device_name: str): devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", []) device_cfg = next( (d for d in devices_cfg if (d.get("name") or d.get("host")) == device_name), None, ) if device_cfg is None: from quart import abort abort(404) hours = int(request.args.get("hours", 24)) async with current_app.db_sessionmaker() as db: hist = await _history(db, device_name, hours=hours) import json history_json = json.dumps(hist) oid_labels = [o.get("label") or o.get("oid") for o in device_cfg.get("oids", [])] return await render_template( "snmp/device.html", device=device_cfg, device_name=device_name, oid_labels=oid_labels, history_json=history_json, hours=hours, )