feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped

The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-08 10:54:11 -04:00
co-authored by Claude Opus 5.5
parent 14474f9be4
commit 9ca2e38fa1
27 changed files with 1873 additions and 703 deletions
+17 -36
View File
@@ -1,56 +1,37 @@
"""The dashboard SNMP widget (#5255): a verdict, devices that stopped answering
first, status in words, and key readings with their units."""
"""The dashboard SNMP widget (#5255, milestone 491): a verdict, devices not
answering first with since when, status in words, two readings in 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)}
def _dev(name, reach, ok_minutes=None, key=()):
now = datetime.now(timezone.utc)
return {"id": f"id-{name}", "name": name, "reach": reach, "says": "No reply.",
"polled_at": now - timedelta(seconds=30),
"ok_at": now - timedelta(minutes=ok_minutes) if ok_minutes is not None else None,
"key": [{"name": n, "text": t} for n, t in 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")])]
devices = [_dev("ups", "down", 14, [("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)
counts={"down": 1, "stale": 0, "pending": 1, "up": 1}, total=3)
page = " ".join(page.split())
assert "ups: no reply since" in page and ">14m ago<" in page
assert "ups isn't answering." in page and "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
assert "not polled yet" in page and "Uptime</span> 12d 4h" 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)
counts={"down": 0, "stale": 0, "pending": 0, "up": 1}, total=1)
empty = await render_plugin_template(app, "snmp", "snmp/widget.html", devices=[], more=0,
counts={}, total=0)
assert "All 1 device answering." in page and "—" in page
assert "No SNMP devices yet." in empty