From 0d603bc7dde286b1de911549c8592ec0ba0a495a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 7 Oct 2026 13:27:26 -0400 Subject: [PATCH] feat(snmp): Devices widget gives a verdict, status in words and readable key readings (#5255) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #5255 "SNMP — Devices: status in words and key readings". - Verdict first: "All N devices answering", "ups: no reply since 14m ago", "N of M devices not answering", or "N not polled yet". - Devices that stopped answering come first, then those never polled, then the rest. - One header row: Device / Readings / Polled. - The name is the link; the separate "detail →" is gone. - The hollow dot is replaced by words: "no data yet", or "no reply since …; readings are from then". - Key readings: the first two in config order, gauges ahead of counters. Units come from the label suffix: _centisecs as a duration, _pct, _dV as volts, _octets as bytes total. Co-Authored-By: Claude Opus 5.5 --- plugins/snmp/routes.py | 57 ++++++++++++++--- plugins/snmp/templates/snmp/widget.html | 84 +++++++++++-------------- tests/plugins/snmp/test_snmp_widget.py | 56 +++++++++++++++++ 3 files changed, 138 insertions(+), 59 deletions(-) create mode 100644 tests/plugins/snmp/test_snmp_widget.py diff --git a/plugins/snmp/routes.py b/plugins/snmp/routes.py index b11abc1..cec73e6 100644 --- a/plugins/snmp/routes.py +++ b/plugins/snmp/routes.py @@ -7,6 +7,7 @@ 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 @@ -160,28 +161,64 @@ async def host_panel(host_id: str): 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[:10]) + latest = await _latest_readings(db, device_names) now = datetime.now(timezone.utc) devices = [] - for cfg in devices_cfg[:10]: + for cfg in devices_cfg: name = cfg.get("name") or cfg.get("host", "?") - devices.append({ - "name": name, - "oids": cfg.get("oids", []), - **_device_state(latest.get(name), _poll_seconds(), now), - }) - + 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, - total=len(devices_cfg)) + devices=devices[:DEVICES_SHOWN], + more=max(0, len(devices) - DEVICES_SHOWN), + counts=counts, total=len(devices)) @snmp_bp.get("/device/") diff --git a/plugins/snmp/templates/snmp/widget.html b/plugins/snmp/templates/snmp/widget.html index 687da20..ca6b787 100644 --- a/plugins/snmp/templates/snmp/widget.html +++ b/plugins/snmp/templates/snmp/widget.html @@ -1,51 +1,37 @@ -{# plugins/snmp/templates/snmp/widget.html #} -{% if devices %} -{% for device in devices %} -
-
- - {{ device.name }} - - {% if device.reach == 'up' %} - ● - {% elif device.reach == 'stale' %} - no answer since {{ device.at|ago }} - {% else %} - ○ - {% endif %} - detail → -
- {% if device.readings %} -
- {% for oid_cfg in device.oids[:4] %} - {% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %} - {% set val = device.readings.get(label) %} - {% if val is not none %} - - {{ label }} - - {% if val >= 1000000 %}{{ "%.1f"|format(val / 1000000) }}M - {% elif val >= 1000 %}{{ "%.0f"|format(val / 1000) }}K - {% else %}{{ "%.4g"|format(val) }}{% endif %} - - - {% endif %} - {% endfor %} - {% if device.oids|length > 4 %} - +{{ device.oids|length - 4 }} more - {% endif %} -
- {% endif %} -
-{% if not loop.last %} -
-{% endif %} -{% endfor %} -{% if total > devices|length %} -
- +{{ total - devices|length }} more — view all → -
-{% endif %} +{# plugins/snmp/templates/snmp/widget.html — the dashboard SNMP widget (#5255): + a verdict, devices that stopped answering first with since when, then the + rest, each with its status in words and its key readings, named and with + units. The device name links to its page. #} +{% from "_macros.html" import status_dot %} +{% if not total %} +
No SNMP devices configured.
{% else %} -

No SNMP devices configured.

+{% set stale = devices | selectattr("reach", "equalto", "stale") | list %} +
+ {%- if counts.stale == 1 and stale %}{{ stale[0].name }}: no reply since {{ stale[0].at|ago }}. + {%- elif counts.stale %}{{ counts.stale }} of {{ total }} devices not answering. + {%- elif counts.pending %}{{ counts.pending }} not polled yet, {{ counts.up }} answering. + {%- else %}All {{ total }} device{{ 's' if total != 1 }} answering.{% endif -%} +
+ + + + + {% for d in devices %} + + + + + + {% endfor %} + +
DeviceReadingsPolled
+ {{- status_dot('down' if d.reach == 'stale' else ('pending' if d.reach == 'pending' else 'up')) -}} + {% if session.user_id %}{{ d.name }}{% else %}{{ d.name }}{% endif %} + {%- if d.reach == 'stale' %}no reply since {{ d.at|ago }}; readings are from then + {%- elif d.reach == 'pending' %}no data yet{% endif %} + + {%- for n, v in d.key %}{{ n }} {{ v }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor -%} + {% if d.at %}{{ d.at|ago }}{% else %}—{% endif %}
+{% if more %}
+{{ more }} more{% if session.user_id %} · all devices{% endif %}
{% endif %} {% endif %} diff --git a/tests/plugins/snmp/test_snmp_widget.py b/tests/plugins/snmp/test_snmp_widget.py new file mode 100644 index 0000000..9d8823e --- /dev/null +++ b/tests/plugins/snmp/test_snmp_widget.py @@ -0,0 +1,56 @@ +"""The dashboard SNMP widget (#5255): a verdict, devices that stopped answering +first, status in words, and key readings with their units.""" +from datetime import datetime, timedelta, timezone + +import pytest + +from plugins.snmp.routes import key_readings, snmp_reading +from tests._templates import render_plugin_template + + +@pytest.mark.parametrize("label, value, says", [ + ("uptime_centisecs", 105_120_000, ("uptime", "12d 4h")), + ("battery_charge_pct", 99.6, ("battery charge", "100%")), + ("input_voltage_dV", 1203, ("input voltage", "120.3 V")), + ("if1_in_octets", 1536, ("if1 in", "1.5 KiB total")), + ("battery_status", 2, ("battery status", "2")), + ("temperature", 41.25, ("temperature", "41.25")), +]) +def test_a_reading_reads_with_its_unit(label, value, says): + assert snmp_reading(label, value) == says + + +def test_key_readings_prefer_gauges_to_counters_in_config_order(): + oids = [{"oid": "a", "label": "if1_in_octets"}, {"oid": "b", "label": "uptime_centisecs"}, + {"oid": "c", "label": "if1_out_octets"}, {"oid": "1.2.3"}] + readings = {"if1_in_octets": 10, "uptime_centisecs": 6000, "if1_out_octets": 20, "1.2.3": 7} + assert [n for n, _ in key_readings(oids, readings)] == ["uptime", "1.2.3"] + assert [n for n, _ in key_readings(oids[:1] + oids[2:3], readings)] == ["if1 in", "if1 out"] + assert key_readings(oids, {}) == [] + + +def _dev(name, reach, minutes_ago=None, key=()): + at = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago) if minutes_ago is not None else None + return {"name": name, "reach": reach, "at": at, "readings": {}, "key": list(key)} + + +async def test_a_silent_device_leads_with_since_when(app): + devices = [_dev("ups", "stale", 14, [("battery charge", "100%")]), _dev("printer", "pending"), + _dev("switch", "up", 1, [("uptime", "12d 4h")])] + async with app.test_request_context("/plugins/snmp/widget"): + page = await render_plugin_template(app, "snmp", "snmp/widget.html", devices=devices, more=0, + counts={"stale": 1, "pending": 1, "up": 1}, total=3) + page = " ".join(page.split()) + assert "ups: no reply since" in page and ">14m ago<" in page + assert page.count("Device") == 1 + assert page.index('title="ups"') < page.index('title="printer"') < page.index('title="switch"') + assert "no data yet" in page and "uptime 12d 4h" in page + assert "detail →" not in page and "○" not in page + + +async def test_all_answering_says_so(app): + async with app.test_request_context("/plugins/snmp/widget"): + page = await render_plugin_template(app, "snmp", "snmp/widget.html", + devices=[_dev("switch", "up", 1)], more=0, + counts={"stale": 0, "pending": 0, "up": 1}, total=1) + assert "All 1 device answering." in page and "—" in page