feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
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:
@@ -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:
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user