From 51682f130ad9c8ca3cde107463b05390380c1f13 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 20 Jun 2026 21:22:39 -0400 Subject: [PATCH] feat(snmp): surface SNMP device readings on the matching host page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SNMP devices are config-defined by IP/hostname, not Steward Host records, so they had no presence on a host's page. Map them by address and embed a fragment (mirrors the Docker per-host fragment). - _devices_for_host(devices_cfg, address, name): case-insensitive match of a device's configured host to the Steward host's address or name (tolerates non-dict / host-less entries). - Route /plugins/snmp/host/: renders the matched device(s) + latest readings, or nothing when none map (so hosts without SNMP carry no empty card). - snmp/host_panel.html: per-device card (name · address · reachability) with a readings grid (K/M scaling) and a History link to the full device page. - hosts/detail.html embeds it after the Docker fragment, gated on snmp enabled. - Unit test for the matching helper (by address, by name, no-match, blank). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_016Jg27rgypiW2efULXJDtMC --- plugins/snmp/routes.py | 42 ++++++++++++++++++ plugins/snmp/templates/snmp/host_panel.html | 47 +++++++++++++++++++++ steward/templates/hosts/detail.html | 5 +++ tests/plugins/snmp/test_host_match.py | 29 +++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 plugins/snmp/templates/snmp/host_panel.html create mode 100644 tests/plugins/snmp/test_host_match.py diff --git a/plugins/snmp/routes.py b/plugins/snmp/routes.py index 94bffbc..6dc19f6 100644 --- a/plugins/snmp/routes.py +++ b/plugins/snmp/routes.py @@ -93,6 +93,48 @@ async def index(): poll_interval=poll_interval) +def _devices_for_host(devices_cfg: list, address: str | None, name: str | None) -> list[dict]: + """SNMP devices whose configured `host` matches a Steward host's address or + name (case-insensitive). SNMP devices are config-defined by IP/hostname, so + this is how they map onto a host's page.""" + keys = {(address or "").strip().lower(), (name or "").strip().lower()} + keys.discard("") + return [ + d for d in devices_cfg + if isinstance(d, dict) and str(d.get("host", "")).strip().lower() in keys + ] + + +@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) + 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", "?"), {}), + } 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(): diff --git a/plugins/snmp/templates/snmp/host_panel.html b/plugins/snmp/templates/snmp/host_panel.html new file mode 100644 index 0000000..38166cc --- /dev/null +++ b/plugins/snmp/templates/snmp/host_panel.html @@ -0,0 +1,47 @@ +{# Per-host SNMP fragment — embedded on the Hosts hub via HTMX. Self-contained. + Shows the SNMP device(s) whose address maps to this host + latest readings. #} +
+
+

SNMP

+ All devices → +
+ + {% for device in devices %} +
+
+ {{ device.name }} + {{ device.host }} + {% if device.readings %} + ● reachable + {% else %} + ○ no data yet + {% endif %} + History +
+ + {% if device.readings %} +
+ {% for oid_cfg in device.oids %} + {% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %} + {% set val = device.readings.get(label) %} +
+
{{ label }}
+ {% if val is not none %} +
+ {% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}M + {% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}K + {% else %}{{ "%.4g"|format(val) }}{% endif %} +
+ {% else %} +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +

No readings yet — waiting for the next poll.

+ {% endif %} +
+ {% endfor %} +
diff --git a/steward/templates/hosts/detail.html b/steward/templates/hosts/detail.html index 02cd301..f553834 100644 --- a/steward/templates/hosts/detail.html +++ b/steward/templates/hosts/detail.html @@ -224,6 +224,11 @@
{% endif %} +{# ── SNMP (renders nothing unless a configured SNMP device maps to this host) ── #} +{% if "snmp" in enabled_plugins %} +
+{% endif %} + {{ toggle_script() }} {% endblock %} diff --git a/tests/plugins/snmp/test_host_match.py b/tests/plugins/snmp/test_host_match.py new file mode 100644 index 0000000..282c7ea --- /dev/null +++ b/tests/plugins/snmp/test_host_match.py @@ -0,0 +1,29 @@ +"""Unit tests for mapping config-defined SNMP devices onto a Steward host.""" +from plugins.snmp.routes import _devices_for_host + +_CFG = [ + {"name": "core-switch", "host": "192.168.1.1"}, + {"name": "ups", "host": "192.168.1.10"}, + {"name": "by-name", "host": "edge-router"}, + "not-a-dict", # tolerated, skipped + {"name": "no-host"}, # no host → never matches +] + + +def test_matches_by_address(): + out = _devices_for_host(_CFG, "192.168.1.1", "somehost") + assert [d["name"] for d in out] == ["core-switch"] + + +def test_matches_by_name_case_insensitive(): + out = _devices_for_host(_CFG, "10.0.0.9", "Edge-Router") + assert [d["name"] for d in out] == ["by-name"] + + +def test_no_match_returns_empty(): + assert _devices_for_host(_CFG, "10.0.0.1", "nope") == [] + + +def test_blank_address_and_name_match_nothing(): + # A host with no address/name must not match a device with a blank host. + assert _devices_for_host(_CFG, "", None) == []