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
+28 -3
View File
@@ -6,6 +6,8 @@ An entity deleted since keeps whatever name the entry's own detail recorded.
"""
from __future__ import annotations
import logging
from sqlalchemy import select
from steward.ansible.inventory_gen import run_title, scope_labels
@@ -18,6 +20,8 @@ from steward.models.api_tokens import ApiToken
from steward.models.hosts import Host
from steward.models.monitors import Monitor
logger = logging.getLogger(__name__)
# entity_type -> (model, its page for an id). A type named by a value rather
# than a row (plugin, ansible_playbook) is its own label.
_NAMED = {
@@ -33,6 +37,23 @@ _NAMED = {
_SELF_NAMED = {"plugin": "/settings/plugins/", "ansible_playbook": None}
def _named_types() -> dict:
"""Core's named entity types plus those a loaded plugin names through its
get_audit_entities() hook ({type: (model, page for an id)}), so core
reads a plugin's rows without importing it."""
from steward.core.plugin_manager import get_loaded_plugin_modules
named = dict(_NAMED)
for name, module in get_loaded_plugin_modules().items():
hook = getattr(module, "get_audit_entities", None)
if hook is None:
continue
try:
named.update(hook() or {})
except Exception:
logger.exception("Plugin %r: get_audit_entities() raised; its entries show by id", name)
return named
def _recorded_name(detail: dict) -> str | None:
"""A name the entry recorded when it was written, for a deleted entity."""
for key in ("name", "new_name", "username", "playbook"):
@@ -50,10 +71,11 @@ async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]:
if e.entity_type and e.entity_id:
ids.setdefault(e.entity_type, set()).add(str(e.entity_id))
named = _named_types()
labels: dict[tuple[str, str], dict] = {}
for etype, wanted in ids.items():
if etype in _NAMED:
model, page = _NAMED[etype]
if etype in named:
model, page = named[etype]
key_col = model.id
keys = [int(i) for i in wanted if i.isdigit()] if etype == "api_token" else list(wanted)
if not keys:
@@ -142,6 +164,9 @@ ACTIONS = {
"api_token.create": "created AI access token",
"api_token.revoke": "revoked AI access token",
"docker.prune.started": "started a Docker cleanup on host",
"snmp.device_created": "added SNMP device",
"snmp.device_updated": "changed SNMP device",
"snmp.device_deleted": "deleted SNMP device",
}
# The filter's choices: an area, matched as a prefix of the action code.
@@ -149,7 +174,7 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("host.", "Hosts"), ("
("alert_rule.", "Alert rules"), ("maintenance_window.", "Maintenance"),
("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"),
("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"),
("docker.", "Docker"))
("docker.", "Docker"), ("snmp.", "SNMP"))
def action_text(action: str) -> str:
+1
View File
@@ -287,6 +287,7 @@ select { cursor: pointer; }
textarea { resize: vertical; }
/* Form fields: .field wraps a .field-label, the .input and an optional .field-hint. */
.field { display: flex; flex-direction: column; gap: 0.2rem; margin-bottom: 0.9rem; }
.field[hidden] { display: none; }
.field-label { display: block; margin-bottom: 0; font-size: 0.75rem; color: var(--text-muted); }
.field-hint { font-size: 0.75rem; color: var(--text-dim); }
.field-error { font-size: 0.85rem; color: var(--red); margin-bottom: 0.75rem; }