# 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 func, select from steward.auth.middleware import require_role from steward.models.users import UserRole 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[str, float]]: """Return {device_name: {label: latest_value}} for all configured devices.""" if not device_names: return {} # Latest value per (resource_name, metric_name) pair subq = ( select( PluginMetric.resource_name, PluginMetric.metric_name, func.max(PluginMetric.recorded_at).label("latest_at"), ) .where(PluginMetric.source_module == "snmp") .where(PluginMetric.resource_name.in_(device_names)) .group_by(PluginMetric.resource_name, PluginMetric.metric_name) .subquery() ) result = await db.execute( select(PluginMetric).join( subq, (PluginMetric.resource_name == subq.c.resource_name) & (PluginMetric.metric_name == subq.c.metric_name) & (PluginMetric.recorded_at == subq.c.latest_at), ).where(PluginMetric.source_module == "snmp") ) rows = result.scalars().all() out: dict[str, dict[str, float]] = {} for row in rows: out.setdefault(row.resource_name, {})[row.metric_name] = row.value return out 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) # 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", []), "readings": latest.get(name, {}), }) 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) devices = [{ "name": d.get("name") or d.get("host", "?"), "host": d.get("host", ""), "oids": d.get("oids", []), "readings": latest.get(d.get("name") or d.get("host", "?"), {}), "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) @snmp_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): 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[:10]) devices = [] for cfg in devices_cfg[:10]: name = cfg.get("name") or cfg.get("host", "?") devices.append({ "name": name, "oids": cfg.get("oids", []), "readings": latest.get(name, {}), }) return await render_template("snmp/widget.html", devices=devices, total=len(devices_cfg)) @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, )