# plugins/snmp/routes.py """The SNMP pages. Every GET reads Steward's own records: the device rows the editor writes and the poller annotates, and the readings it stores. Only Test, an admin's explicit action, asks a device anything (System 178's ruling).""" from __future__ import annotations import json from datetime import datetime, timedelta, timezone from quart import Blueprint, abort, current_app, redirect, render_template, request, session from sqlalchemy import delete, select, update from steward.auth.middleware import require_role from steward.core.audit import log_audit from steward.core.metric_series import latest_by_resource from steward.models.metrics import PluginMetric, PluginMetricLatest from steward.models.users import UserRole from .models import SnmpDevice from .readings import INTERFACES_LABEL, PRESETS, PRESET_ORDER, UNITS, chart_axis, reading_text, stored_unit from .service import (FormError, device_state, form_credentials, form_values, parse_form, readings_for) snmp_bp = Blueprint("snmp", __name__, template_folder="templates") def _poll_seconds() -> int: return int(current_app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60)) async def _all_devices(db) -> list[SnmpDevice]: return list((await db.execute(select(SnmpDevice).order_by(SnmpDevice.name))).scalars().all()) async def _latest(db, names: list[str]) -> dict[str, dict]: """{device name: {key: value}} from plugin_metric_latest.""" latest = await latest_by_resource(db, "snmp", names) return {name: {m: r.value for m, r in metrics.items()} for name, metrics in latest.items()} def shown_readings(device: SnmpDevice, values: dict) -> list[dict]: """The device's readings in its own order, each named and in its unit (a state in the device's own word); "—" for one with no value yet.""" return [{"key": r.key, "name": r.name, "counter": r.counter, "text": reading_text(r, values.get(r.key), device.texts)} for r in readings_for(device)] def view(device: SnmpDevice, values: dict, now: datetime) -> dict: state = device_state(device, _poll_seconds(), now) names = {r.key: r.name for r in readings_for(device)} return {"id": device.id, "name": device.name, "address": device.address, "port": device.port, "host_id": device.host_id, "version": device.version, "reach": state.reach, "says": state.says, "polled_at": device.last_polled_at, "ok_at": device.last_ok_at, "readings": shown_readings(device, values), "missing": [names.get(k, k) for k in device.missing or []]} _REACH_ORDER = {"down": 0, "stale": 1, "pending": 2, "up": 3} @snmp_bp.get("/") @require_role(UserRole.viewer) async def index(): async with current_app.db_sessionmaker() as db: devices = await _all_devices(db) latest = await _latest(db, [d.name for d in devices]) now = datetime.now(timezone.utc) shown = [view(d, latest.get(d.name, {}), now) for d in devices] shown.sort(key=lambda v: (_REACH_ORDER[v["reach"]], v["name"].lower())) return await render_template("snmp/index.html", devices=shown) # ── The editor ──────────────────────────────────────────────────────────────── async def _hosts(db) -> list: from steward.models.hosts import Host return list((await db.execute(select(Host.id, Host.name).order_by(Host.name))).all()) async def _render_form(device: SnmpDevice | None, values: dict, error: str | None = None, status: int = 200): async with current_app.db_sessionmaker() as db: hosts = await _hosts(db) presets = [(pid, *(INTERFACES_LABEL if pid == "interfaces" else PRESETS[pid][:2])) for pid in PRESET_ORDER] return await render_template("snmp/form.html", device=device, v=values, error=error, hosts=hosts, presets=presets, units=UNITS), status def _actor() -> tuple[str | None, str]: return session.get("user_id"), session.get("username", "") @snmp_bp.get("/devices/new") @require_role(UserRole.admin) async def new_device(): return await _render_form(None, form_values(None)) @snmp_bp.post("/devices/new") @require_role(UserRole.admin) async def create_device(): form = await request.form try: values = parse_form(form) except FormError as exc: return await _render_form(None, _typed(form), str(exc), 422) async with current_app.db_sessionmaker() as db: async with db.begin(): if await _name_taken(db, values["name"]): return await _render_form(None, _typed(form), f"A device is already named “{values['name']}”.", 422) device = SnmpDevice(**values) db.add(device) await db.flush() device_id = device.id await log_audit(current_app, *_actor(), "snmp.device_created", entity_type="snmp_device", entity_id=device_id, detail={"name": values["name"], "address": values["address"]}) return redirect(f"/plugins/snmp/devices/{device_id}") async def _name_taken(db, name: str, other_than: str | None = None) -> bool: q = select(SnmpDevice.id).where(SnmpDevice.name == name) if other_than: q = q.where(SnmpDevice.id != other_than) return (await db.execute(q)).first() is not None def _typed(form) -> dict: """What was typed, to show the form again with an error. Secrets are not echoed back.""" return {"name": form.get("name", ""), "address": form.get("address", ""), "port": form.get("port", ""), "version": form.get("version", "2c"), "v3_user": form.get("v3_user", ""), "v3_auth": form.get("v3_auth", "none"), "v3_priv": form.get("v3_priv", "none"), "presets": form.getlist("presets"), "interfaces": form.get("interfaces", ""), "custom": form.get("custom", ""), "host_id": form.get("host_id", "")} async def _device_or_404(db, device_id: str) -> SnmpDevice: device = await db.get(SnmpDevice, device_id) if device is None: abort(404) return device @snmp_bp.get("/devices//edit") @require_role(UserRole.admin) async def edit_device(device_id: str): async with current_app.db_sessionmaker() as db: device = await _device_or_404(db, device_id) return await _render_form(device, form_values(device)) @snmp_bp.post("/devices//edit") @require_role(UserRole.admin) async def save_device(device_id: str): form = await request.form async with current_app.db_sessionmaker() as db: async with db.begin(): device = await _device_or_404(db, device_id) try: values = parse_form(form, device) except FormError as exc: return await _render_form(device, {**_typed(form), **_kept_secrets(device)}, str(exc), 422) if await _name_taken(db, values["name"], other_than=device.id): return await _render_form(device, {**_typed(form), **_kept_secrets(device)}, f"A device is already named “{values['name']}”.", 422) old_name = device.name for column, value in values.items(): setattr(device, column, value) # What it is asked, or how, may have changed: the last outcome no # longer describes it, so the page says "not polled yet" until the # next poll, which also walks its vendor tables again. device.last_polled_at = device.last_ok_at = device.last_error = device.tables_at = None device.missing, device.texts = [], {} if old_name != device.name: # Its history is stored under its name; move it with the rename. for model in (PluginMetric, PluginMetricLatest): await db.execute(update(model).where( model.source_module == "snmp", model.resource_name == old_name ).values(resource_name=device.name)) from . import scheduler if scheduler.RATES is not None: scheduler.RATES.forget(device_id) detail = {"name": values["name"]} if old_name != values["name"]: detail["old_name"] = old_name await log_audit(current_app, *_actor(), "snmp.device_updated", entity_type="snmp_device", entity_id=device_id, detail=detail) return redirect(f"/plugins/snmp/devices/{device_id}") def _kept_secrets(device: SnmpDevice) -> dict: return {"has_community": bool(device.community), "has_auth_key": bool(device.v3_auth_key), "has_priv_key": bool(device.v3_priv_key)} @snmp_bp.post("/devices//delete") @require_role(UserRole.admin) async def delete_device(device_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): device = await _device_or_404(db, device_id) name = device.name await db.delete(device) # Its newest readings go with it, so nothing reads it as a device # that stopped answering. Its history ages out with retention. await db.execute(delete(PluginMetricLatest).where( PluginMetricLatest.source_module == "snmp", PluginMetricLatest.resource_name == name)) await log_audit(current_app, *_actor(), "snmp.device_deleted", entity_type="snmp_device", entity_id=device_id, detail={"name": name}) return redirect("/plugins/snmp/") @snmp_bp.post("/devices/test") @require_role(UserRole.admin) async def test_device(): """Ask the device on the (unsaved) form who it is and what interfaces it has. An admin's explicit action, so it may contact the device.""" from .poller import describe_device form = await request.form existing = None if form.get("device_id"): async with current_app.db_sessionmaker() as db: existing = await db.get(SnmpDevice, form["device_id"]) address, port, creds = form_credentials(form, existing) if not address: found = {"error": "Enter the device's address first."} else: found = await describe_device(address, port, creds) return await render_template("snmp/_test.html", found=found, address=address, port=port) # ── The device page, the dashboard widget, the host page's panel ───────────── async def _history(db, device_name: str, since: datetime) -> dict[str, list]: """{key: [(iso time, value), ...]} for one device since `since`.""" rows = (await db.execute( select(PluginMetric.metric_name, PluginMetric.recorded_at, PluginMetric.value) .where(PluginMetric.source_module == "snmp", PluginMetric.resource_name == device_name, PluginMetric.recorded_at >= since) .order_by(PluginMetric.recorded_at))).all() out: dict[str, list] = {} for key, at, value in rows: out.setdefault(key, []).append((at.isoformat(), value)) return out RANGES = (1, 6, 24, 168) @snmp_bp.get("/devices/") @require_role(UserRole.viewer) async def device_detail(device_id: str): hours = request.args.get("hours", type=int, default=24) if hours not in RANGES: hours = 24 now = datetime.now(timezone.utc) async with current_app.db_sessionmaker() as db: device = await _device_or_404(db, device_id) latest = await _latest(db, [device.name]) hist = await _history(db, device.name, now - timedelta(hours=hours)) charts = [] for r in readings_for(device): unit = stored_unit(r) axis, factor = chart_axis(unit) charts.append({"key": r.key, "name": r.name, "axis": axis, "now": reading_text(r, latest.get(device.name, {}).get(r.key), device.texts), "points": [(t, v * factor) for t, v in hist.get(r.key, [])]}) return await render_template("snmp/device.html", device=view(device, latest.get(device.name, {}), now), charts=charts, charts_json=json.dumps(charts), hours=hours, ranges=RANGES) DEVICES_SHOWN = 10 @snmp_bp.get("/widget") @require_role(UserRole.viewer) async def widget(): """The dashboard SNMP widget (#5255), in widget shape: a verdict, devices not answering first with why, then the rest with two readings each.""" async with current_app.db_sessionmaker() as db: devices = await _all_devices(db) latest = await _latest(db, [d.name for d in devices]) now = datetime.now(timezone.utc) shown = [view(d, latest.get(d.name, {}), now) for d in devices] shown.sort(key=lambda v: (_REACH_ORDER[v["reach"]], v["name"].lower())) for v in shown: v["key"] = [r for r in v["readings"] if r["text"] != "—"][:2] counts = {r: sum(1 for d in shown if d["reach"] == r) for r in _REACH_ORDER} return await render_template("snmp/widget.html", devices=shown[:DEVICES_SHOWN], more=max(0, len(shown) - DEVICES_SHOWN), counts=counts, total=len(shown)) def devices_for_host(devices: list[SnmpDevice], host_id: str, address: str | None, name: str | None) -> list[SnmpDevice]: """The devices shown on a host's page: those bound to it, and those bound to no host whose address is the host's address or name. A binding is exclusive, so a device bound elsewhere never matches by address.""" keys = {(address or "").strip().lower(), (name or "").strip().lower()} - {""} return [d for d in devices if (d.host_id == host_id if d.host_id else d.address.strip().lower() in keys)] @snmp_bp.get("/host/") @require_role(UserRole.viewer) async def host_panel(host_id: str): """The SNMP panel on a host's page; nothing when no device maps to it.""" from steward.models.hosts import Host async with current_app.db_sessionmaker() as db: host = await db.get(Host, host_id) if host is None: return "" matched = devices_for_host(await _all_devices(db), host.id, host.address, host.name) if not matched: return "" latest = await _latest(db, [d.name for d in matched]) now = datetime.now(timezone.utc) return await render_template("snmp/host_panel.html", devices=[{**view(d, latest.get(d.name, {}), now), "bound": bool(d.host_id)} for d in matched])