feat(snmp): Devices widget gives a verdict, status in words and readable key readings (#5255)
#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:
+47
-10
@@ -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>")
|
||||
|
||||
@@ -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') }}">●</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);">○</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 %}
|
||||
|
||||
@@ -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 "○" 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
|
||||
Reference in New Issue
Block a user