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) == []