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.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/<device_name>")
+35 -49
View File
@@ -1,51 +1,37 @@
{# plugins/snmp/templates/snmp/widget.html #}
{% if devices %}
{% for device in devices %}
<div class="ping-row" style="align-items:flex-start;flex-direction:column;gap:0.3rem;padding:0.4rem 0;">
<div style="display:flex;align-items:center;gap:0.6rem;width:100%;">
<span style="font-size:0.82rem;font-weight:500;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">
{{ device.name }}
</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 %}
{# 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 %}
<div class="w-verdict" style="color:var(--text-muted);">No SNMP devices configured.</div>
{% 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 %}