feat(snmp): Devices widget gives a verdict, status in words and readable key readings (#5255)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 51s
CI / unit (push) Successful in 1m17s
CI / publish (push) Successful in 1m16s

#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 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 13:27:26 -04:00
co-authored by Claude Opus 5.5
parent a720403b47
commit 0d603bc7dd
3 changed files with 138 additions and 59 deletions
+47 -10
View File
@@ -7,6 +7,7 @@ from sqlalchemy import select
from steward.auth.middleware import require_role from steward.auth.middleware import require_role
from steward.models.users import UserRole 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.core.metric_series import latest_by_resource
from steward.models.metrics import PluginMetric 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) 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") @snmp_bp.get("/widget")
@require_role(UserRole.viewer) @require_role(UserRole.viewer)
async def widget(): 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", []) 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] device_names = [d.get("name") or d.get("host", "?") for d in devices_cfg]
async with current_app.db_sessionmaker() as db: 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) now = datetime.now(timezone.utc)
devices = [] devices = []
for cfg in devices_cfg[:10]: for cfg in devices_cfg:
name = cfg.get("name") or cfg.get("host", "?") name = cfg.get("name") or cfg.get("host", "?")
devices.append({ state = _device_state(latest.get(name), _poll_seconds(), now)
"name": name, devices.append({"name": name, **state,
"oids": cfg.get("oids", []), "key": key_readings(cfg.get("oids", []), state["readings"])})
**_device_state(latest.get(name), _poll_seconds(), now), 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", return await render_template("snmp/widget.html",
devices=devices, devices=devices[:DEVICES_SHOWN],
total=len(devices_cfg)) more=max(0, len(devices) - DEVICES_SHOWN),
counts=counts, total=len(devices))
@snmp_bp.get("/device/<device_name>") @snmp_bp.get("/device/<device_name>")
+35 -49
View File
@@ -1,51 +1,37 @@
{# plugins/snmp/templates/snmp/widget.html #} {# plugins/snmp/templates/snmp/widget.html — the dashboard SNMP widget (#5255):
{% if devices %} a verdict, devices that stopped answering first with since when, then the
{% for device in devices %} rest, each with its status in words and its key readings, named and with
<div class="ping-row" style="align-items:flex-start;flex-direction:column;gap:0.3rem;padding:0.4rem 0;"> units. The device name links to its page. #}
<div style="display:flex;align-items:center;gap:0.6rem;width:100%;"> {% from "_macros.html" import status_dot %}
<span style="font-size:0.82rem;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"> {% if not total %}
{{ device.name }} <div class="w-verdict" style="color:var(--text-muted);">No SNMP devices configured.</div>
</span>
{% if device.reach == 'up' %}
<span style="font-size:0.7rem;" class="tone-ok" title="Reachable · read {{ device.at.strftime('%H:%M UTC') }}">&#9679;</span>
{% elif device.reach == 'stale' %}
<span style="font-size:0.7rem;" class="tone-warn">no answer since {{ device.at|ago }}</span>
{% else %}
<span style="font-size:0.7rem;color:var(--text-dim);">&#9675;</span>
{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" style="font-size:0.72rem;color:var(--text-muted);">detail →</a>
</div>
{% if device.readings %}
<div style="display:flex;flex-wrap:wrap;gap:0.5rem 1rem;padding-left:0.1rem;">
{% 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 %}
<span style="font-size:0.78rem;font-variant-numeric:tabular-nums;">
<span style="color:var(--text-muted);">{{ label }}</span>
<span style="margin-left:0.25rem;">
{% if val >= 1000000 %}{{ "%.1f"|format(val / 1000000) }}M
{% elif val >= 1000 %}{{ "%.0f"|format(val / 1000) }}K
{% else %}{{ "%.4g"|format(val) }}{% endif %}
</span>
</span>
{% endif %}
{% endfor %}
{% if device.oids|length > 4 %}
<span style="font-size:0.72rem;color:var(--text-dim);">+{{ device.oids|length - 4 }} more</span>
{% endif %}
</div>
{% endif %}
</div>
{% if not loop.last %}
<div style="height:1px;background:var(--border);margin:0.1rem 0;"></div>
{% endif %}
{% endfor %}
{% if total > devices|length %}
<div style="color:var(--text-dim);font-size:0.75rem;padding:0.25rem 0;">
+{{ total - devices|length }} more — <a href="/plugins/snmp/">view all →</a>
</div>
{% endif %}
{% else %} {% else %}
<p class="empty" style="padding:0.5rem 0;font-size:0.85rem;">No SNMP devices configured.</p> {% set stale = devices | selectattr("reach", "equalto", "stale") | list %}
<div class="w-verdict {{ 'tone-crit' if counts.stale else ('tone-warn' if counts.pending else 'tone-ok') }}">
{%- 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 -%}
</div>
<table class="w-table">
<colgroup><col style="width:40%"><col><col class="w-wide-only" style="width:6rem"></colgroup>
<thead><tr><th>Device</th><th>Readings</th><th class="w-wide-only">Polled</th></tr></thead>
<tbody>
{% for d in devices %}
<tr>
<td class="w-name" title="{{ d.name }}">
{{- status_dot('down' if d.reach == 'stale' else ('pending' if d.reach == 'pending' else 'up')) -}}
{% if session.user_id %}<a href="/plugins/snmp/device/{{ d.name }}">{{ d.name }}</a>{% else %}{{ d.name }}{% endif %}
{%- if d.reach == 'stale' %}<span class="w-sub tone-crit">no reply since {{ d.at|ago }}; readings are from then</span>
{%- elif d.reach == 'pending' %}<span class="w-sub">no data yet</span>{% endif %}
</td>
<td title="{% for n, v in d.key %}{{ n }} {{ v }}{% if not loop.last %} · {% endif %}{% endfor %}">
{%- for n, v in d.key %}<span style="color:var(--text-muted);">{{ n }}</span> {{ v }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor -%}
</td>
<td class="w-wide-only">{% if d.at %}{{ d.at|ago }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if more %}<div class="w-more">+{{ more }} more{% if session.user_id %} · <a href="/plugins/snmp/">all devices</a>{% endif %}</div>{% endif %}
{% endif %} {% endif %}
+56
View File
@@ -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("<th>Device</th>") == 1
assert page.index('title="ups"') < page.index('title="printer"') < page.index('title="switch"')
assert "no data yet" in page and "uptime</span> 12d 4h" in page
assert "detail →" not in page and "&#9675;" 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