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
+7
View File
@@ -11,6 +11,7 @@ _app: "Quart | None" = None
def setup(app: "Quart") -> None:
global _app
_app = app
from .models import SnmpDevice # noqa: F401 — registers the table
def get_scheduled_tasks() -> list:
@@ -32,3 +33,9 @@ def get_alert_metrics() -> dict[str, list[str]]:
"""SNMP metric names are the OID labels each device is configured with, so
the list is empty here and the rule form offers the labels recorded so far."""
return {"snmp": []}
def get_audit_entities() -> dict:
"""Audit rows about a device name it and link to its page."""
from .models import SnmpDevice
return {"snmp_device": (SnmpDevice, "/plugins/snmp/devices/{}")}
View File
@@ -0,0 +1,49 @@
"""SNMP devices are rows edited on the SNMP page
Devices lived in plugin.yaml's config, which the UI could not edit, and a poll
that failed left no trace. A row per device holds what to ask and how, and the
newest poll's outcome (milestone 491).
Revision ID: snmp_001_devices
Revises: (none — a branch off core via depends_on)
Create Date: 2026-10-08
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "snmp_001_devices"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = "snmp"
depends_on: Union[str, Sequence[str], None] = ("0004_app_settings",)
def upgrade() -> None:
op.create_table(
"snmp_devices",
sa.Column("id", sa.String(36), primary_key=True),
sa.Column("name", sa.String(100), nullable=False, unique=True),
sa.Column("address", sa.String(255), nullable=False),
sa.Column("port", sa.Integer, nullable=False, server_default="161"),
sa.Column("version", sa.String(3), nullable=False, server_default="2c"),
sa.Column("community", sa.Text, nullable=False, server_default=""),
sa.Column("v3_user", sa.String(100), nullable=False, server_default=""),
sa.Column("v3_auth", sa.String(16), nullable=False, server_default="none"),
sa.Column("v3_auth_key", sa.Text, nullable=False, server_default=""),
sa.Column("v3_priv", sa.String(16), nullable=False, server_default="none"),
sa.Column("v3_priv_key", sa.Text, nullable=False, server_default=""),
sa.Column("presets", sa.JSON, nullable=False, server_default="[]"),
sa.Column("interfaces", sa.String(255), nullable=False, server_default=""),
sa.Column("custom", sa.Text, nullable=False, server_default=""),
sa.Column("host_id", sa.String(36), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_polled_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_ok_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_error", sa.Text, nullable=True),
sa.Column("missing", sa.JSON, nullable=False, server_default="[]"),
)
def downgrade() -> None:
op.drop_table("snmp_devices")
+42
View File
@@ -0,0 +1,42 @@
# plugins/snmp/models.py
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import JSON, DateTime, Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from steward.core.ids import time_ordered_id
from steward.models.base import Base
class SnmpDevice(Base):
"""A device Steward polls over SNMP, edited on the SNMP page.
Secrets (the community, the v3 keys) are stored encrypted. The last_*
columns are the newest poll's outcome, written by the poller, so a page
can say why a device isn't answering without asking the device itself.
"""
__tablename__ = "snmp_devices"
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
name: Mapped[str] = mapped_column(String(100), nullable=False, unique=True)
address: Mapped[str] = mapped_column(String(255), nullable=False)
port: Mapped[int] = mapped_column(Integer, nullable=False, default=161)
version: Mapped[str] = mapped_column(String(3), nullable=False, default="2c")
community: Mapped[str] = mapped_column(Text, nullable=False, default="")
v3_user: Mapped[str] = mapped_column(String(100), nullable=False, default="")
v3_auth: Mapped[str] = mapped_column(String(16), nullable=False, default="none")
v3_auth_key: Mapped[str] = mapped_column(Text, nullable=False, default="")
v3_priv: Mapped[str] = mapped_column(String(16), nullable=False, default="none")
v3_priv_key: Mapped[str] = mapped_column(Text, nullable=False, default="")
presets: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
interfaces: Mapped[str] = mapped_column(String(255), nullable=False, default="")
custom: Mapped[str] = mapped_column(Text, nullable=False, default="")
host_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
last_polled_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_ok_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
missing: Mapped[list] = mapped_column(JSON, nullable=False, default=list)
+3 -45
View File
@@ -1,7 +1,7 @@
# plugins/snmp/plugin.yaml
name: snmp
version: "1.0.0"
description: "SNMP polling for network devices — switches, routers, printers, UPSes, etc."
version: "2.0.0"
description: "SNMP polling for network devices — switches, routers, printers, UPSes, servers."
author: "Steward"
license: "MIT"
min_app_version: "0.1.0"
@@ -12,48 +12,6 @@ tags:
- network
- monitoring
# Devices are added, tested and edited on the SNMP page (/plugins/snmp/).
config:
poll_interval_seconds: 60
# Each device is polled independently. OIDs must resolve to numeric values.
#
# `host` is the SNMP poll target (IP/hostname we query). A device also shows
# up on a Steward Host's detail page when it maps to that host. By default
# that mapping is implicit: `host` is matched against the Host's address or
# name. To bind explicitly instead — e.g. when you poll by IP but the Host is
# recorded by DNS name, or for an SNMP-only switch/PDU/UPS — add ONE of:
# host_id: "<steward-host-uuid>" # exact Host.id match
# steward_host: "switch01" # Host name OR address (case-insensitive)
# An explicit binding is exclusive: the device then maps ONLY to that host,
# never implicitly to another by a coincidental address string.
devices:
- name: "core-switch"
host: "192.168.1.1"
port: 161
community: "public"
version: "2c" # "1", "2c", or "3"
oids:
- oid: "1.3.6.1.2.1.1.3.0"
label: "uptime_centisecs"
- oid: "1.3.6.1.2.1.2.2.1.10.1"
label: "if1_in_octets"
- oid: "1.3.6.1.2.1.2.2.1.16.1"
label: "if1_out_octets"
# Example: network UPS with SNMP management card (RFC 1628 UPS-MIB).
# upsBatteryStatus values: 1=unknown, 2=batteryNormal, 3=batteryLow, 4=batteryDepleted.
# Uncomment and point at your UPS to enable.
# - name: "ups"
# host: "192.168.1.10"
# port: 161
# community: "public"
# version: "2c"
# oids:
# - oid: "1.3.6.1.2.1.33.1.2.1.0"
# label: "battery_status"
# - oid: "1.3.6.1.2.1.33.1.2.2.0"
# label: "seconds_on_battery"
# - oid: "1.3.6.1.2.1.33.1.2.3.0"
# label: "runtime_minutes_remaining"
# - oid: "1.3.6.1.2.1.33.1.2.4.0"
# label: "battery_charge_pct"
# - oid: "1.3.6.1.2.1.33.1.3.3.1.3.1"
# label: "input_voltage_dV"
+195 -100
View File
@@ -1,19 +1,53 @@
# plugins/snmp/poller.py
"""
Asynchronous SNMP GET helper.
"""Ask an SNMP device for its readings, and say in words why it didn't answer.
Requires pysnmp-lextudio (the maintained pysnmp fork), bundled into the Docker
image via the `snmp` extra (`pip install .[snmp]`):
pip install 'steward[snmp]'
If pysnmp is not installed, poll_device() returns an empty dict and logs a
warning — SNMP polling is then simply disabled, nothing else breaks.
pysnmp-lextudio 6.x (what the image installs) and canonical pysnmp 7.x put the
asyncio API in different modules with different names; _api() finds whichever
is installed, so a dependency bump can't silently break polling. Without
pysnmp at all the poll reports that instead of raising.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from types import SimpleNamespace
logger = logging.getLogger(__name__)
TIMEOUT_SECONDS = 5
# OIDs per GET. A device's whole list usually fits in one request; this keeps
# a long custom list under the size an agent will answer.
OIDS_PER_REQUEST = 24
AUTH_PROTOCOLS = {"none": "usmNoAuthProtocol", "md5": "usmHMACMD5AuthProtocol",
"sha": "usmHMACSHAAuthProtocol", "sha224": "usmHMAC128SHA224AuthProtocol",
"sha256": "usmHMAC192SHA256AuthProtocol", "sha384": "usmHMAC256SHA384AuthProtocol",
"sha512": "usmHMAC384SHA512AuthProtocol"}
PRIV_PROTOCOLS = {"none": "usmNoPrivProtocol", "des": "usmDESPrivProtocol", "3des": "usm3DESEDEPrivProtocol",
"aes128": "usmAesCfb128Protocol", "aes192": "usmAesCfb192Protocol",
"aes256": "usmAesCfb256Protocol"}
# A varbind value of one of these types means the agent has no such object.
_ABSENT = {"NoSuchObject", "NoSuchInstance", "EndOfMibView"}
@dataclass
class Credentials:
version: str = "2c"
community: str = ""
user: str = ""
auth: str = "none"
auth_key: str = ""
priv: str = "none"
priv_key: str = ""
@dataclass
class PollResult:
readings: dict[str, float] = field(default_factory=dict)
error: str | None = None # the device didn't answer, and why
missing: list[str] = field(default_factory=list) # keys it answered without
def _pysnmp_available() -> bool:
try:
@@ -23,9 +57,27 @@ def _pysnmp_available() -> bool:
return False
def _mp_model(version: str) -> int:
"""Map an SNMP version string to pysnmp's mpModel int (0 = v1, 1 = v2c)."""
return 0 if version == "1" else 1
def _api() -> SimpleNamespace:
"""The installed pysnmp's asyncio API under one set of names."""
try:
from pysnmp.hlapi.v3arch import asyncio as hl
from pysnmp.hlapi.v3arch.asyncio import auth
return SimpleNamespace(hl=hl, auth=auth, get=hl.get_cmd, walk=hl.walk_cmd, async_transport=True)
except ImportError:
from pysnmp.hlapi import asyncio as hl
from pysnmp.hlapi.asyncio import auth
return SimpleNamespace(hl=hl, auth=auth, get=hl.getCmd, walk=hl.walkCmd, async_transport=False)
def _auth_data(api, creds: Credentials):
if creds.version == "3":
return api.hl.UsmUserData(
creds.user,
authKey=creds.auth_key or None,
privKey=creds.priv_key or None,
authProtocol=getattr(api.auth, AUTH_PROTOCOLS.get(creds.auth, "usmNoAuthProtocol")),
privProtocol=getattr(api.auth, PRIV_PROTOCOLS.get(creds.priv, "usmNoPrivProtocol")))
return api.hl.CommunityData(creds.community, mpModel=0 if creds.version == "1" else 1)
def _close_engine(engine) -> None:
@@ -66,106 +118,149 @@ def _close_engine(engine) -> None:
return
async def poll_device(
host: str,
port: int,
community: str,
version: str,
oids: list[dict],
) -> dict[str, float]:
"""Perform an SNMP GET for each OID and return ``{label: float_value}``.
def explain(error_indication, address: str, port: int, creds: Credentials) -> str:
"""pysnmp's error indication, said as what to check."""
text = str(error_indication)
low = text.lower()
if "timeout" in low or "no snmp response" in low:
who = (f"the v3 user “{creds.user}”" if creds.version == "3"
else "the community")
return (f"No reply from {address}:{port} within {TIMEOUT_SECONDS} s. Check the address and port, "
f"that SNMP is on, and {who}: a device ignores a request it doesn't accept.")
if "unknown" in low and "user" in low:
return f"The device doesn't know the v3 user “{creds.user}”."
if "digest" in low or "authentication" in low:
return "The device refused the v3 authentication: check the auth protocol and key."
if "decrypt" in low or "privacy" in low:
return "The device couldn't decrypt the request: check the privacy protocol and key."
if "not in time window" in low:
return "The device rejected the request as out of its time window; try again."
return f"SNMP error from {address}:{port}: {text}"
Non-numeric OIDs (strings, etc.) are skipped. Returns an empty dict on any
error (unreachable host, wrong community, …) so a flaky device never breaks
the poll loop.
pysnmp's HLAPI is asyncio-only as of v6. The import path moved between major
versions, so we support both rather than let a dependency bump silently
re-break polling:
• pysnmp-lextudio 6.2.x → ``pysnmp.hlapi.asyncio`` (``getCmd`` + a directly
constructed ``UdpTransportTarget``).
• canonical pysnmp 7.x → ``pysnmp.hlapi.v3arch.asyncio`` (``get_cmd`` + the
async ``UdpTransportTarget.create``).
async def _transport(api, address: str, port: int):
if api.async_transport:
return await api.hl.UdpTransportTarget.create((address, port), timeout=TIMEOUT_SECONDS, retries=1)
return api.hl.UdpTransportTarget((address, port), timeout=TIMEOUT_SECONDS, retries=1)
async def poll_device(address: str, port: int, creds: Credentials,
oids: list[tuple[str, str]]) -> PollResult:
"""GET every (oid, key) of a device, a few dozen to a request.
Returns the numeric answers by key, the keys the device answered without
(no such object, or not a number), and an error in words when it didn't
answer at all. Never raises: a broken device must not stop the others.
"""
result = PollResult()
if not oids:
return result
if not _pysnmp_available():
logger.warning("pysnmp not installed — SNMP polling disabled. "
"Install with: pip install 'steward[snmp]'")
return {}
result.error = "pysnmp isn't installed in this image, so SNMP polling is off."
return result
api = _api()
engine = api.hl.SnmpEngine()
try:
# canonical pysnmp 7.x
from pysnmp.hlapi.v3arch.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
get_cmd as _get_cmd,
)
_transport_is_async = True
except ImportError:
# pysnmp-lextudio 6.2.x
from pysnmp.hlapi.asyncio import (
CommunityData,
ContextData,
ObjectIdentity,
ObjectType,
SnmpEngine,
UdpTransportTarget,
getCmd as _get_cmd,
)
_transport_is_async = False
engine = SnmpEngine()
# Always release the engine's UDP socket — see _close_engine for why.
try:
# Same host/port for every OID on this device, so build the transport once.
try:
if _transport_is_async:
transport = await UdpTransportTarget.create((host, port), timeout=5, retries=1)
else:
transport = UdpTransportTarget((host, port), timeout=5, retries=1)
transport = await _transport(api, address, port)
except Exception as exc:
logger.debug("SNMP transport setup failed for %s:%s: %s", host, port, exc)
return {}
result.error = f"Can't reach {address}:{port}: {exc}"
return result
auth = _auth_data(api, creds)
results: dict[str, float] = {}
for oid_cfg in oids:
oid = oid_cfg["oid"]
label = oid_cfg.get("label") or oid
scale = float(oid_cfg.get("scale", 1.0))
try:
error_indication, error_status, error_index, var_binds = await _get_cmd(
engine,
CommunityData(community, mpModel=_mp_model(version)),
transport,
ContextData(),
ObjectType(ObjectIdentity(oid)),
)
except Exception as exc:
logger.debug("SNMP GET %s@%s OID %s failed: %s", host, port, oid, exc)
continue
if error_indication:
logger.debug("SNMP error %s@%s OID %s: %s", host, port, oid, error_indication)
continue
if error_status:
logger.debug("SNMP status %s@%s OID %s: %s at %s",
host, port, oid, error_status.prettyPrint(),
error_index and var_binds[int(error_index) - 1][0] or "?")
continue
for _, val in var_binds:
pending = list(oids)
while pending:
chunk, pending = pending[:OIDS_PER_REQUEST], pending[OIDS_PER_REQUEST:]
ei, es, eidx, var_binds = await api.get(
engine, auth, transport, api.hl.ContextData(),
*[api.hl.ObjectType(api.hl.ObjectIdentity(oid)) for oid, _ in chunk],
lookupMib=False)
if ei:
result.error = explain(ei, address, port, creds)
return result
if es:
# v1 fails the whole request on the first unknown OID: note it,
# and ask again for the rest.
bad = int(eidx) - 1 if eidx else -1
if 0 <= bad < len(chunk):
result.missing.append(chunk[bad][1])
pending = chunk[:bad] + chunk[bad + 1:] + pending
continue
result.error = f"The device answered with an error: {es.prettyPrint()}"
return result
# A GET answers in the order it asked.
for (_, key), (_, val) in zip(chunk, var_binds):
if type(val).__name__ in _ABSENT:
result.missing.append(key)
continue
try:
results[label] = float(val) * scale
result.readings[key] = float(val)
except (TypeError, ValueError):
# Non-numeric type (e.g. OctetString description) — skip.
logger.debug("SNMP non-numeric value for %s label=%s: %r", oid, label, val)
result.missing.append(key)
if not result.readings and result.missing:
result.error = "The device answered but has none of these readings."
return result
except Exception as exc:
logger.warning("SNMP poll of %s:%s failed: %s", address, port, exc)
result.error = f"SNMP poll failed: {exc}"
return result
finally:
_close_engine(engine)
return results
SYS_DESCR, SYS_UPTIME, SYS_NAME = "1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.3.0", "1.3.6.1.2.1.1.5.0"
IF_DESCR, IF_NAME = "1.3.6.1.2.1.2.2.1.2", "1.3.6.1.2.1.31.1.1.1.1"
INTERFACES_LISTED = 128
async def describe_device(address: str, port: int, creds: Credentials) -> dict:
"""What Test shows: {"error"} or {"name", "description", "uptime_cs",
"interfaces": [(number, name)]}. Asks the device directly, so only an
operator's explicit Test calls it, never a page load."""
if not _pysnmp_available():
return {"error": "pysnmp isn't installed in this image, so SNMP is off."}
api = _api()
engine = api.hl.SnmpEngine()
try:
try:
transport = await _transport(api, address, port)
except Exception as exc:
return {"error": f"Can't reach {address}:{port}: {exc}"}
auth = _auth_data(api, creds)
ei, es, _, var_binds = await api.get(
engine, auth, transport, api.hl.ContextData(),
*[api.hl.ObjectType(api.hl.ObjectIdentity(o)) for o in (SYS_NAME, SYS_DESCR, SYS_UPTIME)],
lookupMib=False)
if ei:
return {"error": explain(ei, address, port, creds)}
if es:
return {"error": f"The device answered with an error: {es.prettyPrint()}"}
values = [None if type(v).__name__ in _ABSENT else v for _, v in var_binds]
out = {"name": str(values[0]) if values[0] is not None else "",
"description": str(values[1]) if values[1] is not None else "",
"uptime_cs": float(values[2]) if values[2] is not None else None,
"interfaces": []}
# ifName where the device has it (IF-MIB), else ifDescr.
for column in (IF_NAME, IF_DESCR):
found: list[tuple[int, str]] = []
async for ei, es, _, rows in api.walk(engine, auth, transport, api.hl.ContextData(),
api.hl.ObjectType(api.hl.ObjectIdentity(column)),
lexicographicMode=False, lookupMib=False):
if ei or es:
break
for name, val in rows:
if type(val).__name__ in _ABSENT:
continue
found.append((int(str(name).rsplit(".", 1)[-1]), str(val)))
if len(found) >= INTERFACES_LISTED:
break
if found:
out["interfaces"] = found
break
return out
except Exception as exc:
logger.warning("SNMP test of %s:%s failed: %s", address, port, exc)
return {"error": f"SNMP test failed: {exc}"}
finally:
_close_engine(engine)
+213
View File
@@ -0,0 +1,213 @@
# plugins/snmp/readings.py
"""What an SNMP device is asked for, and how each answer reads.
A device's readings come from the presets ticked on its form plus any custom
lines. Each reading has a key (the metric name it is stored and alerted on),
a name a person reads, and a unit. A counter (an interface's byte count, any
"total so far") is stored as a rate: the poller turns two samples into a
per-second figure, so a chart and an alert see traffic, not a lifetime total.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from steward.core.formatting import human_bytes, human_duration
@dataclass(frozen=True)
class Reading:
oid: str
key: str
name: str
unit: str = "number"
scale: float = 1.0
offset: float = 0.0
@property
def counter(self) -> bool:
return self.unit in COUNTER_UNITS
# A counter's unit names what it counts; it is stored as the rate unit it maps
# to, multiplied by the factor (a byte counter is stored as bits per second).
COUNTER_UNITS = {"octets": ("bits_per_second", 8.0), "count": ("per_second", 1.0)}
STATUS_WORDS = {
"if_status": {1: "up", 2: "down", 3: "testing", 4: "unknown", 5: "dormant",
6: "not present", 7: "lower layer down"},
"ups_battery": {1: "unknown", 2: "normal", 3: "low", 4: "depleted"},
}
# The units a custom line may name, with how each one reads, for the form's hint.
UNITS = {
"number": "a plain number",
"percent": "0 to 100",
"celsius": "degrees Celsius",
"volts": "volts",
"decivolts": "tenths of a volt",
"seconds": "a span in seconds",
"minutes": "a span in minutes",
"centiseconds": "a span in hundredths of a second (sysUpTime)",
"bytes": "a size in bytes",
"kilobytes": "a size in KiB",
"octets": "a byte counter, shown as traffic (bit/s)",
"count": "any other counter, shown per second",
}
_UPTIME = Reading("1.3.6.1.2.1.1.3.0", "uptime", "Uptime", "centiseconds")
# id: (label, what it is for, readings). Interface traffic is per interface
# and built by interface_readings from the numbers on the form.
PRESETS: dict[str, tuple[str, str, tuple[Reading, ...]]] = {
"system": ("System", "How long the device has been up. Nearly every SNMP device answers it.",
(_UPTIME,)),
"ups": ("UPS", "Battery state, charge, runtime left, load and input voltage (UPS-MIB, RFC 1628; "
"APC, Eaton and CyberPower management cards).",
(Reading("1.3.6.1.2.1.33.1.2.1.0", "battery_status", "Battery", "ups_battery"),
Reading("1.3.6.1.2.1.33.1.2.4.0", "battery_charge", "Charge", "percent"),
Reading("1.3.6.1.2.1.33.1.2.3.0", "runtime_left", "Runtime left", "minutes"),
Reading("1.3.6.1.2.1.33.1.4.4.1.5.1", "load", "Load", "percent"),
Reading("1.3.6.1.2.1.33.1.3.3.1.3.1", "input_voltage", "Input", "volts"),
Reading("1.3.6.1.2.1.33.1.2.2.0", "on_battery_for", "On battery for", "seconds"))),
"linux": ("Linux server", "CPU, memory and load from net-snmp (snmpd's UCD-SNMP-MIB).",
(Reading("1.3.6.1.4.1.2021.11.11.0", "cpu_used", "CPU used", "percent", scale=-1.0, offset=100.0),
Reading("1.3.6.1.4.1.2021.10.1.5.1", "load_1m", "Load (1m)", "number", scale=0.01),
Reading("1.3.6.1.4.1.2021.4.6.0", "memory_free", "Memory free", "kilobytes"),
Reading("1.3.6.1.4.1.2021.4.5.0", "memory_total", "Memory total", "kilobytes"))),
}
PRESET_ORDER = ("system", "interfaces", "ups", "linux")
INTERFACES_LABEL = ("Interface traffic", "Traffic in and out and link state for the interfaces numbered "
"below. Test the device to see its interfaces and their numbers.")
def parse_interfaces(text: str) -> list[int]:
"""'1, 2, 5-7' → [1, 2, 5, 6, 7]. Raises ValueError naming what it can't read."""
out: list[int] = []
for part in re.split(r"[,\s]+", (text or "").strip()):
if not part:
continue
m = re.fullmatch(r"(\d+)(?:-(\d+))?", part)
if not m:
raise ValueError(f"“{part}” is not an interface number or range")
lo, hi = int(m.group(1)), int(m.group(2) or m.group(1))
if hi < lo or hi - lo > 64:
raise ValueError(f"“{part}” is not a range of up to 64 interfaces")
out.extend(n for n in range(lo, hi + 1) if n not in out)
return out
def interface_readings(numbers: list[int], version: str) -> list[Reading]:
"""In, out and link state per interface. v2c and v3 read the 64-bit
counters (ifHCInOctets), which don't wrap on a fast link; v1 has only the
32-bit ones (ifInOctets)."""
hc = version != "1"
out: list[Reading] = []
for n in numbers:
rx = f"1.3.6.1.2.1.31.1.1.1.6.{n}" if hc else f"1.3.6.1.2.1.2.2.1.10.{n}"
tx = f"1.3.6.1.2.1.31.1.1.1.10.{n}" if hc else f"1.3.6.1.2.1.2.2.1.16.{n}"
out += [Reading(f"1.3.6.1.2.1.2.2.1.8.{n}", f"if{n}_status", f"Interface {n}", "if_status"),
Reading(rx, f"if{n}_in", f"Interface {n} in", "octets"),
Reading(tx, f"if{n}_out", f"Interface {n} out", "octets")]
return out
_OID = re.compile(r"\.?\d+(\.\d+)+")
_KEY = re.compile(r"[a-z][a-z0-9_]{0,63}")
def parse_custom(text: str) -> list[Reading]:
"""One reading a line: `OID name [unit]`. The name is a single word
(letters, digits, underscores) and becomes the metric name; the unit is
one of UNITS and defaults to number. Raises ValueError naming the line."""
out: list[Reading] = []
for i, raw in enumerate((text or "").splitlines(), start=1):
line = raw.split("#", 1)[0].strip()
if not line:
continue
parts = line.split()
if len(parts) not in (2, 3):
raise ValueError(f"Line {i}: write it as “OID name” or “OID name unit”")
oid, key = parts[0].lstrip("."), parts[1].lower()
unit = parts[2].lower() if len(parts) == 3 else "number"
if not _OID.fullmatch(oid):
raise ValueError(f"Line {i}: “{parts[0]}” is not an OID (numbers separated by dots)")
if not _KEY.fullmatch(key):
raise ValueError(f"Line {i}: the name “{parts[1]}” must start with a letter and use "
"only letters, digits and underscores")
if unit not in UNITS:
raise ValueError(f"Line {i}: “{parts[2]}” is not a unit; use one of {', '.join(UNITS)}")
out.append(Reading(oid, key, key.replace("_", " ").capitalize(), unit))
return out
def device_readings(presets: list[str], interfaces: list[int], custom: str, version: str) -> list[Reading]:
"""Every reading a device is asked for, presets in their order then custom
lines. Raises ValueError when two readings share a name."""
out: list[Reading] = []
for pid in PRESET_ORDER:
if pid == "interfaces":
if "interfaces" in presets:
out += interface_readings(interfaces, version)
elif pid in presets:
out += PRESETS[pid][2]
out += parse_custom(custom)
seen: set[str] = set()
for r in out:
if r.key in seen:
raise ValueError(f"Two readings are named “{r.key}”; give the custom one another name")
seen.add(r.key)
return out
def stored_unit(reading: Reading) -> str:
"""The unit a reading is stored in: a counter's rate unit, else its own."""
return COUNTER_UNITS[reading.unit][0] if reading.counter else reading.unit
def _bits(bps: float) -> str:
for unit, size in (("Gbit/s", 1e9), ("Mbit/s", 1e6), ("kbit/s", 1e3)):
if abs(bps) >= size:
return f"{bps / size:.3g} {unit}"
return f"{bps:.0f} bit/s"
def format_value(unit: str, value: float | None) -> str:
"""A stored value as a person reads it, in its unit."""
if value is None:
return "—"
if unit in STATUS_WORDS:
return STATUS_WORDS[unit].get(int(value), f"code {value:.0f}")
if unit == "centiseconds":
return human_duration(value / 100)
if unit == "seconds":
return human_duration(value)
if unit == "minutes":
return human_duration(value * 60)
if unit == "percent":
return f"{value:.0f}%"
if unit == "celsius":
return f"{value:.1f} °C"
if unit == "volts":
return f"{value:.0f} V"
if unit == "decivolts":
return f"{value / 10:.1f} V"
if unit == "bytes":
return human_bytes(value)
if unit == "kilobytes":
return human_bytes(value * 1024)
if unit == "bits_per_second":
return _bits(value)
if unit == "per_second":
return f"{value:.3g}/s"
return f"{value:,.0f}" if float(value).is_integer() else f"{value:.4g}"
# What a chart's axis says for a stored unit, and how its values scale for it.
def chart_axis(unit: str) -> tuple[str, float]:
return {"centiseconds": ("days", 1 / 8_640_000), "seconds": ("seconds", 1.0),
"minutes": ("minutes", 1.0), "percent": ("%", 1.0), "celsius": ("°C", 1.0),
"volts": ("V", 1.0), "decivolts": ("V", 0.1), "bytes": ("MiB", 1 / 1_048_576),
"kilobytes": ("MiB", 1 / 1024), "bits_per_second": ("Mbit/s", 1e-6),
"per_second": ("per second", 1.0)}.get(unit, ("", 1.0))
+275 -207
View File
@@ -1,251 +1,319 @@
# plugins/snmp/routes.py
"""The SNMP pages. Every GET reads Steward's own records: the device rows the
editor writes and the poller annotates, and the readings it stores. Only Test,
an admin's explicit action, asks a device anything (System 178's ruling)."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from quart import Blueprint, current_app, render_template, request
from sqlalchemy import select
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from sqlalchemy import delete, select, update
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.audit import log_audit
from steward.core.metric_series import latest_by_resource
from steward.models.metrics import PluginMetric
from steward.models.metrics import PluginMetric, PluginMetricLatest
from steward.models.users import UserRole
from .models import SnmpDevice
from .readings import (INTERFACES_LABEL, PRESETS, PRESET_ORDER, UNITS, chart_axis, format_value,
stored_unit)
from .service import (FormError, device_state, form_credentials, form_values, parse_form,
readings_for)
snmp_bp = Blueprint("snmp", __name__, template_folder="templates")
async def _latest_readings(db, device_names: list[str]) -> dict[str, dict]:
"""{device_name: {"readings": {label: latest_value}, "at": newest reading's
time}} for the devices that have any readings."""
latest = await latest_by_resource(db, "snmp", device_names)
return {name: {"readings": {m: r.value for m, r in metrics.items()},
"at": max(r.recorded_at for r in metrics.values())}
for name, metrics in latest.items() if metrics}
# A device whose newest reading is older than this many poll intervals is not
# shown as reachable: the poller has stopped getting answers from it.
STALE_POLLS = 3
def _device_state(entry: dict | None, poll_seconds: int, now: datetime) -> dict:
"""What the page says about a device's readings: {"readings", "at",
"reach"}, reach being "up" (answered within STALE_POLLS polls), "stale"
(has readings, but none recent) or "pending" (never answered)."""
if not entry:
return {"readings": {}, "at": None, "reach": "pending"}
fresh = (now - entry["at"]).total_seconds() <= STALE_POLLS * max(int(poll_seconds), 1)
return {**entry, "reach": "up" if fresh else "stale"}
def _poll_seconds() -> int:
return int(current_app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60))
async def _history(db, device_name: str, hours: int = 24) -> dict[str, list]:
"""Return {label: [(recorded_at, value), ...]} for a single device."""
since = datetime.now(timezone.utc) - timedelta(hours=hours)
result = await db.execute(
select(PluginMetric)
.where(
PluginMetric.source_module == "snmp",
PluginMetric.resource_name == device_name,
PluginMetric.recorded_at >= since,
)
.order_by(PluginMetric.recorded_at)
)
rows = result.scalars().all()
async def _all_devices(db) -> list[SnmpDevice]:
return list((await db.execute(select(SnmpDevice).order_by(SnmpDevice.name))).scalars().all())
out: dict[str, list] = {}
for row in rows:
out.setdefault(row.metric_name, []).append(
(row.recorded_at.isoformat(), row.value)
)
return out
async def _latest(db, names: list[str]) -> dict[str, dict]:
"""{device name: {key: value}} from plugin_metric_latest."""
latest = await latest_by_resource(db, "snmp", names)
return {name: {m: r.value for m, r in metrics.items()} for name, metrics in latest.items()}
def shown_readings(device: SnmpDevice, values: dict) -> list[dict]:
"""The device's readings in its own order, each named and in its unit;
"—" for one with no value yet."""
return [{"key": r.key, "name": r.name, "counter": r.counter,
"text": format_value(stored_unit(r), values.get(r.key))}
for r in readings_for(device)]
def view(device: SnmpDevice, values: dict, now: datetime) -> dict:
state = device_state(device, _poll_seconds(), now)
names = {r.key: r.name for r in readings_for(device)}
return {"id": device.id, "name": device.name, "address": device.address, "port": device.port,
"version": device.version, "reach": state.reach, "says": state.says,
"polled_at": device.last_polled_at, "ok_at": device.last_ok_at,
"readings": shown_readings(device, values),
"missing": [names.get(k, k) for k in device.missing or []]}
_REACH_ORDER = {"down": 0, "stale": 1, "pending": 2, "up": 3}
@snmp_bp.get("/")
@require_role(UserRole.viewer)
async def index():
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)
devices = await _all_devices(db)
latest = await _latest(db, [d.name for d in devices])
now = datetime.now(timezone.utc)
# Merge config + latest readings for the template
devices = []
for cfg in devices_cfg:
name = cfg.get("name") or cfg.get("host", "?")
devices.append({
"name": name,
"host": cfg.get("host", ""),
"oids": cfg.get("oids", []),
**_device_state(latest.get(name), _poll_seconds(), now),
})
poll_interval = current_app.config.get("MONITORS_POLL_INTERVAL", 60)
return await render_template("snmp/index.html",
devices=devices,
poll_interval=poll_interval)
shown = [view(d, latest.get(d.name, {}), now) for d in devices]
shown.sort(key=lambda v: (_REACH_ORDER[v["reach"]], v["name"].lower()))
return await render_template("snmp/index.html", devices=shown)
def _device_binds_to_host(d: dict, keys: set[str], host_id: str) -> bool:
"""Does config device `d` belong on the host identified by `keys`
(lowercased {address, name}) / `host_id`?
# ── The editor ────────────────────────────────────────────────────────────────
A device may declare an **explicit** binding that decouples "which Steward
host this belongs to" from "where to poll" (the `host` field):
• `host_id` — the Steward Host UUID (exact match), the explicit link.
• `steward_host` — a friendly bind by Host name or address (case-insensitive).
An explicit binding is **exclusive**: a device bound to host A must not also
match host B by a coincidental poll-target string. Only when no explicit
binding is present do we fall back to the implicit `host`-string match
(backward compatible with configs that only set `host`).
"""
explicit_id = str(d.get("host_id", "")).strip()
explicit_name = str(d.get("steward_host", "")).strip().lower()
if explicit_id or explicit_name:
if explicit_id and host_id and explicit_id == host_id:
return True
return bool(explicit_name and explicit_name in keys)
return str(d.get("host", "")).strip().lower() in keys
def _devices_for_host(devices_cfg: list, address: str | None, name: str | None,
host_id: str | None = None) -> list[dict]:
"""SNMP devices that map onto a Steward host's page. Prefers an explicit
per-device binding (`host_id` / `steward_host`); otherwise falls back to
matching the device's `host` (poll target) against the host's address or
name (case-insensitive). See `_device_binds_to_host` for the precedence."""
keys = {(address or "").strip().lower(), (name or "").strip().lower()}
keys.discard("")
hid = (host_id or "").strip()
return [
d for d in devices_cfg
if isinstance(d, dict) and _device_binds_to_host(d, keys, hid)
]
@snmp_bp.get("/host/<host_id>")
@require_role(UserRole.viewer)
async def host_panel(host_id: str):
"""Per-host SNMP fragment for the Hosts hub. Surfaces any configured SNMP
device that maps to this host (by address or name) with its latest readings.
Renders nothing when no device maps, so hosts without SNMP carry no empty card.
"""
async def _hosts(db) -> list:
from steward.models.hosts import Host
return list((await db.execute(select(Host.id, Host.name).order_by(Host.name))).all())
devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
async def _render_form(device: SnmpDevice | None, values: dict, error: str | None = None, status: int = 200):
async with current_app.db_sessionmaker() as db:
host = (await db.execute(
select(Host).where(Host.id == host_id))).scalar_one_or_none()
if host is None:
return ""
matched = _devices_for_host(devices_cfg, host.address, host.name, host.id)
if not matched:
return ""
names = [d.get("name") or d.get("host", "?") for d in matched]
latest = await _latest_readings(db, names)
hosts = await _hosts(db)
presets = [(pid, *(INTERFACES_LABEL if pid == "interfaces" else PRESETS[pid][:2])) for pid in PRESET_ORDER]
return await render_template("snmp/form.html", device=device, v=values, error=error, hosts=hosts,
presets=presets, units=UNITS), status
def _actor() -> tuple[str | None, str]:
return session.get("user_id"), session.get("username", "")
@snmp_bp.get("/devices/new")
@require_role(UserRole.admin)
async def new_device():
return await _render_form(None, form_values(None))
@snmp_bp.post("/devices/new")
@require_role(UserRole.admin)
async def create_device():
form = await request.form
try:
values = parse_form(form)
except FormError as exc:
return await _render_form(None, _typed(form), str(exc), 422)
async with current_app.db_sessionmaker() as db:
async with db.begin():
if await _name_taken(db, values["name"]):
return await _render_form(None, _typed(form), f"A device is already named “{values['name']}”.", 422)
device = SnmpDevice(**values)
db.add(device)
await db.flush()
device_id = device.id
await log_audit(current_app, *_actor(), "snmp.device_created", entity_type="snmp_device",
entity_id=device_id, detail={"name": values["name"], "address": values["address"]})
return redirect(f"/plugins/snmp/devices/{device_id}")
async def _name_taken(db, name: str, other_than: str | None = None) -> bool:
q = select(SnmpDevice.id).where(SnmpDevice.name == name)
if other_than:
q = q.where(SnmpDevice.id != other_than)
return (await db.execute(q)).first() is not None
def _typed(form) -> dict:
"""What was typed, to show the form again with an error. Secrets are not
echoed back."""
return {"name": form.get("name", ""), "address": form.get("address", ""), "port": form.get("port", ""),
"version": form.get("version", "2c"), "v3_user": form.get("v3_user", ""),
"v3_auth": form.get("v3_auth", "none"), "v3_priv": form.get("v3_priv", "none"),
"presets": form.getlist("presets"), "interfaces": form.get("interfaces", ""),
"custom": form.get("custom", ""), "host_id": form.get("host_id", "")}
async def _device_or_404(db, device_id: str) -> SnmpDevice:
device = await db.get(SnmpDevice, device_id)
if device is None:
abort(404)
return device
@snmp_bp.get("/devices/<device_id>/edit")
@require_role(UserRole.admin)
async def edit_device(device_id: str):
async with current_app.db_sessionmaker() as db:
device = await _device_or_404(db, device_id)
return await _render_form(device, form_values(device))
@snmp_bp.post("/devices/<device_id>/edit")
@require_role(UserRole.admin)
async def save_device(device_id: str):
form = await request.form
async with current_app.db_sessionmaker() as db:
async with db.begin():
device = await _device_or_404(db, device_id)
try:
values = parse_form(form, device)
except FormError as exc:
return await _render_form(device, {**_typed(form), **_kept_secrets(device)}, str(exc), 422)
if await _name_taken(db, values["name"], other_than=device.id):
return await _render_form(device, {**_typed(form), **_kept_secrets(device)},
f"A device is already named “{values['name']}”.", 422)
old_name = device.name
for column, value in values.items():
setattr(device, column, value)
# What it is asked, or how, may have changed: the last outcome no
# longer describes it, so the page says "not polled yet" until the next poll.
device.last_polled_at = device.last_ok_at = device.last_error = None
device.missing = []
if old_name != device.name:
# Its history is stored under its name; move it with the rename.
for model in (PluginMetric, PluginMetricLatest):
await db.execute(update(model).where(
model.source_module == "snmp", model.resource_name == old_name
).values(resource_name=device.name))
from . import scheduler
if scheduler.RATES is not None:
scheduler.RATES.forget(device_id)
detail = {"name": values["name"]}
if old_name != values["name"]:
detail["old_name"] = old_name
await log_audit(current_app, *_actor(), "snmp.device_updated", entity_type="snmp_device",
entity_id=device_id, detail=detail)
return redirect(f"/plugins/snmp/devices/{device_id}")
def _kept_secrets(device: SnmpDevice) -> dict:
return {"has_community": bool(device.community), "has_auth_key": bool(device.v3_auth_key),
"has_priv_key": bool(device.v3_priv_key)}
@snmp_bp.post("/devices/<device_id>/delete")
@require_role(UserRole.admin)
async def delete_device(device_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
device = await _device_or_404(db, device_id)
name = device.name
await db.delete(device)
# Its newest readings go with it, so nothing reads it as a device
# that stopped answering. Its history ages out with retention.
await db.execute(delete(PluginMetricLatest).where(
PluginMetricLatest.source_module == "snmp", PluginMetricLatest.resource_name == name))
await log_audit(current_app, *_actor(), "snmp.device_deleted", entity_type="snmp_device",
entity_id=device_id, detail={"name": name})
return redirect("/plugins/snmp/")
@snmp_bp.post("/devices/test")
@require_role(UserRole.admin)
async def test_device():
"""Ask the device on the (unsaved) form who it is and what interfaces it
has. An admin's explicit action, so it may contact the device."""
from .poller import describe_device
form = await request.form
existing = None
if form.get("device_id"):
async with current_app.db_sessionmaker() as db:
existing = await db.get(SnmpDevice, form["device_id"])
address, port, creds = form_credentials(form, existing)
if not address:
found = {"error": "Enter the device's address first."}
else:
found = await describe_device(address, port, creds)
return await render_template("snmp/_test.html", found=found, address=address, port=port)
# ── The device page, the dashboard widget, the host page's panel ─────────────
async def _history(db, device_name: str, since: datetime) -> dict[str, list]:
"""{key: [(iso time, value), ...]} for one device since `since`."""
rows = (await db.execute(
select(PluginMetric.metric_name, PluginMetric.recorded_at, PluginMetric.value)
.where(PluginMetric.source_module == "snmp", PluginMetric.resource_name == device_name,
PluginMetric.recorded_at >= since)
.order_by(PluginMetric.recorded_at))).all()
out: dict[str, list] = {}
for key, at, value in rows:
out.setdefault(key, []).append((at.isoformat(), value))
return out
RANGES = (1, 6, 24, 168)
@snmp_bp.get("/devices/<device_id>")
@require_role(UserRole.viewer)
async def device_detail(device_id: str):
hours = request.args.get("hours", type=int, default=24)
if hours not in RANGES:
hours = 24
now = datetime.now(timezone.utc)
devices = [{
"name": d.get("name") or d.get("host", "?"),
"host": d.get("host", ""),
"oids": d.get("oids", []),
**_device_state(latest.get(d.get("name") or d.get("host", "?")), _poll_seconds(), now),
"bound": bool(str(d.get("host_id", "")).strip()
or str(d.get("steward_host", "")).strip()),
} for d in matched]
return await render_template("snmp/host_panel.html", devices=devices)
async with current_app.db_sessionmaker() as db:
device = await _device_or_404(db, device_id)
latest = await _latest(db, [device.name])
hist = await _history(db, device.name, now - timedelta(hours=hours))
charts = []
for r in readings_for(device):
unit = stored_unit(r)
axis, factor = chart_axis(unit)
charts.append({"key": r.key, "name": r.name, "axis": axis,
"now": format_value(unit, latest.get(device.name, {}).get(r.key)),
"points": [(t, v * factor) for t, v in hist.get(r.key, [])]})
return await render_template("snmp/device.html", device=view(device, latest.get(device.name, {}), now),
charts=charts, charts_json=json.dumps(charts), hours=hours, ranges=RANGES)
# 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]
"""The dashboard SNMP widget (#5255), in widget shape: a verdict, devices
not answering first with why, then the rest with two readings each."""
async with current_app.db_sessionmaker() as db:
latest = await _latest_readings(db, device_names)
devices = await _all_devices(db)
latest = await _latest(db, [d.name for d in devices])
now = datetime.now(timezone.utc)
devices = []
for cfg in devices_cfg:
name = cfg.get("name") or cfg.get("host", "?")
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[:DEVICES_SHOWN],
more=max(0, len(devices) - DEVICES_SHOWN),
counts=counts, total=len(devices))
shown = [view(d, latest.get(d.name, {}), now) for d in devices]
shown.sort(key=lambda v: (_REACH_ORDER[v["reach"]], v["name"].lower()))
for v in shown:
v["key"] = [r for r in v["readings"] if r["text"] != "—"][:2]
counts = {r: sum(1 for d in shown if d["reach"] == r) for r in _REACH_ORDER}
return await render_template("snmp/widget.html", devices=shown[:DEVICES_SHOWN],
more=max(0, len(shown) - DEVICES_SHOWN), counts=counts, total=len(shown))
@snmp_bp.get("/device/<device_name>")
def devices_for_host(devices: list[SnmpDevice], host_id: str, address: str | None,
name: str | None) -> list[SnmpDevice]:
"""The devices shown on a host's page: those bound to it, and those bound
to no host whose address is the host's address or name. A binding is
exclusive, so a device bound elsewhere never matches by address."""
keys = {(address or "").strip().lower(), (name or "").strip().lower()} - {""}
return [d for d in devices
if (d.host_id == host_id if d.host_id else d.address.strip().lower() in keys)]
@snmp_bp.get("/host/<host_id>")
@require_role(UserRole.viewer)
async def device_detail(device_name: str):
devices_cfg: list[dict] = current_app.config["PLUGINS"]["snmp"].get("devices", [])
device_cfg = next(
(d for d in devices_cfg if (d.get("name") or d.get("host")) == device_name),
None,
)
if device_cfg is None:
from quart import abort
abort(404)
hours = int(request.args.get("hours", 24))
async def host_panel(host_id: str):
"""The SNMP panel on a host's page; nothing when no device maps to it."""
from steward.models.hosts import Host
async with current_app.db_sessionmaker() as db:
hist = await _history(db, device_name, hours=hours)
import json
history_json = json.dumps(hist)
oid_labels = [o.get("label") or o.get("oid") for o in device_cfg.get("oids", [])]
return await render_template(
"snmp/device.html",
device=device_cfg,
device_name=device_name,
oid_labels=oid_labels,
history_json=history_json,
hours=hours,
)
host = await db.get(Host, host_id)
if host is None:
return ""
matched = devices_for_host(await _all_devices(db), host.id, host.address, host.name)
if not matched:
return ""
latest = await _latest(db, [d.name for d in matched])
now = datetime.now(timezone.utc)
return await render_template("snmp/host_panel.html",
devices=[{**view(d, latest.get(d.name, {}), now), "bound": bool(d.host_id)}
for d in matched])
+54 -34
View File
@@ -1,15 +1,23 @@
# plugins/snmp/scheduler.py
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import TYPE_CHECKING
from sqlalchemy import select
from steward.core.scheduler import ScheduledTask
if TYPE_CHECKING:
from quart import Quart
from steward.core.scheduler import ScheduledTask
logger = logging.getLogger(__name__)
# Counters' previous samples, for their rates (service.RateKeeper).
RATES = None
def make_poll_task(app: "Quart") -> ScheduledTask:
interval = app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60)
@@ -25,46 +33,58 @@ def make_poll_task(app: "Quart") -> ScheduledTask:
)
async def _do_poll(app: "Quart") -> None:
from .poller import poll_device
async def _do_poll(app: "Quart", poll=None) -> None:
"""Poll every device at once, then write each one's outcome to its row
and its answers to the metric tables. `poll` stands in for
poller.poll_device in tests."""
global RATES
from steward.core.alerts import MetricBatch
devices: list[dict] = app.config["PLUGINS"]["snmp"].get("devices", [])
from .models import SnmpDevice
from .poller import poll_device
from .service import RateKeeper, credentials, readings_for
poll = poll or poll_device
if RATES is None:
RATES = RateKeeper()
async with app.db_sessionmaker() as session:
devices = (await session.execute(select(SnmpDevice))).scalars().all()
if not devices:
return
asked = {d.id: readings_for(d) for d in devices}
results = await asyncio.gather(*(
poll(d.address, d.port, credentials(d), [(r.oid, r.key) for r in asked[d.id]])
for d in devices), return_exceptions=True)
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as session:
async with session.begin():
batch = MetricBatch()
for device in devices:
if not isinstance(device, dict):
for device, result in zip(devices, results):
row = await session.get(SnmpDevice, device.id)
if row is None: # deleted while it was being polled
continue
name = device.get("name") or device.get("host", "unknown")
host = device.get("host", "")
port = int(device.get("port", 161))
community = device.get("community", "public")
version = str(device.get("version", "2c"))
oids = device.get("oids", [])
if not host or not oids:
row.last_polled_at = now
if isinstance(result, BaseException):
logger.warning("SNMP poll of %s failed: %s", device.name, result)
row.last_error = f"SNMP poll failed: {result}"
continue
try:
readings = await poll_device(host, port, community, version, oids)
except Exception:
logger.exception("SNMP poll failed for device %s (%s)", name, host)
continue
for label, value in readings.items():
batch.add(
source_module="snmp",
resource_name=name,
metric_name=label,
value=value,
)
if readings:
logger.debug("SNMP polled %s (%s): %d OID(s)", name, host, len(readings))
else:
logger.debug("SNMP polled %s (%s): no readings", name, host)
row.last_error = result.error
row.missing = sorted(set(result.missing))
if result.readings:
row.last_ok_at = now
for reading in asked[device.id]:
value = result.readings.get(reading.key)
if value is None:
continue
if reading.counter:
value = RATES.rate(device.id, reading, value, now)
if value is None:
continue
else:
value = value * reading.scale + reading.offset
batch.add(source_module="snmp", resource_name=device.name,
metric_name=reading.key, value=value, recorded_at=now)
await batch.record(session)
+195
View File
@@ -0,0 +1,195 @@
# plugins/snmp/service.py
"""SNMP devices: the form a person edits, the row it becomes, what the poller
asks for, and what a page says about the newest poll."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from steward.core.crypto import decrypt_secret, encrypt_secret
from .models import SnmpDevice
from .poller import AUTH_PROTOCOLS, PRIV_PROTOCOLS, Credentials
from .readings import PRESETS, Reading, device_readings, parse_interfaces
VERSIONS = ("1", "2c", "3")
PRESET_IDS = set(PRESETS) | {"interfaces"}
SECRET_FIELDS = ("community", "v3_auth_key", "v3_priv_key")
class FormError(ValueError):
"""What is wrong with a submitted device form, said to the person."""
def readings_for(device: SnmpDevice) -> list[Reading]:
"""What the device is asked for. A row saved through the form always
parses; one that somehow doesn't is asked for nothing rather than raising
in the poll loop."""
try:
return device_readings(device.presets or [], parse_interfaces(device.interfaces),
device.custom, device.version)
except ValueError:
return []
def credentials(device: SnmpDevice) -> Credentials:
return Credentials(
version=device.version,
community=decrypt_secret(device.community, context=f"snmp device {device.name} community"),
user=device.v3_user, auth=device.v3_auth,
auth_key=decrypt_secret(device.v3_auth_key, context=f"snmp device {device.name} auth key"),
priv=device.v3_priv,
priv_key=decrypt_secret(device.v3_priv_key, context=f"snmp device {device.name} privacy key"))
def form_values(device: SnmpDevice | None) -> dict:
"""The form's fields for an existing device. Secrets are never sent back
to the browser; a blank secret field on save keeps the stored one."""
if device is None:
return {"port": 161, "version": "2c", "presets": ["system"], "v3_auth": "sha", "v3_priv": "aes128"}
return {"name": device.name, "address": device.address, "port": device.port,
"version": device.version, "v3_user": device.v3_user, "v3_auth": device.v3_auth,
"v3_priv": device.v3_priv, "presets": list(device.presets or []),
"interfaces": device.interfaces, "custom": device.custom, "host_id": device.host_id or "",
"has_community": bool(device.community), "has_auth_key": bool(device.v3_auth_key),
"has_priv_key": bool(device.v3_priv_key)}
def parse_form(form, existing: SnmpDevice | None = None) -> dict:
"""The submitted form as the columns to store, secrets encrypted.
Raises FormError saying what to fix."""
name = (form.get("name") or "").strip()
address = (form.get("address") or "").strip()
if not name:
raise FormError("Give the device a name.")
if len(name) > 100 or "/" in name:
raise FormError("A name is up to 100 characters, without a slash.")
if not address:
raise FormError("Give the device's address: an IP or a hostname.")
try:
port = int(form.get("port") or 161)
except ValueError:
raise FormError("The port is a number, usually 161.") from None
if not 1 <= port <= 65535:
raise FormError("The port is between 1 and 65535, usually 161.")
version = form.get("version") or "2c"
if version not in VERSIONS:
raise FormError("Choose SNMP version 1, 2c or 3.")
presets = [p for p in form.getlist("presets") if p in PRESET_IDS]
interfaces = (form.get("interfaces") or "").strip()
custom = (form.get("custom") or "").strip()
try:
numbers = parse_interfaces(interfaces)
if "interfaces" in presets and not numbers:
raise FormError("Interface traffic needs the interface numbers to read, e.g. 1, 2 or 1-4.")
if not device_readings(presets, numbers, custom, version):
raise FormError("Choose at least one preset or add a custom reading.")
except ValueError as exc:
raise FormError(str(exc)) from None
def secret(field: str) -> str:
typed = form.get(field) or ""
if typed:
return encrypt_secret(typed)
return getattr(existing, field) if existing is not None else ""
values = {"name": name, "address": address, "port": port, "version": version,
"presets": presets, "interfaces": interfaces, "custom": custom,
"host_id": (form.get("host_id") or "").strip() or None,
"community": "", "v3_user": "", "v3_auth": "none", "v3_auth_key": "",
"v3_priv": "none", "v3_priv_key": ""}
if version == "3":
user = (form.get("v3_user") or "").strip()
auth = form.get("v3_auth") or "none"
priv = form.get("v3_priv") or "none"
if not user:
raise FormError("SNMPv3 needs a user name.")
if auth not in AUTH_PROTOCOLS or priv not in PRIV_PROTOCOLS:
raise FormError("Choose an authentication and a privacy protocol from the lists.")
if priv != "none" and auth == "none":
raise FormError("Privacy needs authentication too: choose an authentication protocol.")
values.update(v3_user=user, v3_auth=auth, v3_priv=priv,
v3_auth_key=secret("v3_auth_key") if auth != "none" else "",
v3_priv_key=secret("v3_priv_key") if priv != "none" else "")
for field, proto in (("v3_auth_key", auth), ("v3_priv_key", priv)):
if proto != "none" and not values[field]:
raise FormError("SNMPv3 with " + ("authentication" if field == "v3_auth_key" else "privacy")
+ " needs its key (at least 8 characters).")
if proto != "none" and form.get(field) and len(form.get(field)) < 8:
raise FormError("An SNMPv3 key is at least 8 characters.")
else:
values["community"] = secret("community") or encrypt_secret("public")
return values
def form_credentials(form, existing: SnmpDevice | None = None) -> tuple[str, int, Credentials]:
"""Address, port and credentials from an unsaved form, for Test. A blank
secret falls back to the stored one when testing an existing device."""
def secret(field: str, default: str = "") -> str:
typed = form.get(field) or ""
if typed:
return typed
if existing is not None and getattr(existing, field):
return decrypt_secret(getattr(existing, field))
return default
version = form.get("version") if form.get("version") in VERSIONS else "2c"
try:
port = int(form.get("port") or 161)
except ValueError:
port = 161
creds = Credentials(version=version, community=secret("community", "public"),
user=(form.get("v3_user") or "").strip(), auth=form.get("v3_auth") or "none",
auth_key=secret("v3_auth_key"), priv=form.get("v3_priv") or "none",
priv_key=secret("v3_priv_key"))
return (form.get("address") or "").strip(), port, creds
# A device not polled for this many intervals has stopped being polled
# (plugin turned off, scheduler stuck), whatever its last outcome said.
STALE_POLLS = 3
@dataclass
class DeviceState:
reach: str # up | down | stale | pending
says: str # one line, in words
def device_state(device: SnmpDevice, poll_seconds: int, now: datetime) -> DeviceState:
"""What the newest poll says about the device."""
if device.last_polled_at is None:
return DeviceState("pending", "Not polled yet.")
if (now - device.last_polled_at).total_seconds() > STALE_POLLS * max(int(poll_seconds), 1):
return DeviceState("stale", "Not polled recently: the SNMP plugin may be turned off.")
if device.last_error:
return DeviceState("down", device.last_error)
return DeviceState("up", "Answering.")
class RateKeeper:
"""A counter's previous sample per (device, key), to turn the next one
into a per-second rate. In memory: after a restart the first poll of each
counter stores nothing and the second stores its rate. A sample lower
than the one before (the device restarted, or a 32-bit counter wrapped)
is skipped rather than read as a huge negative or wrapped rate."""
def __init__(self) -> None:
self._last: dict[tuple[str, str], tuple[float, datetime]] = {}
def rate(self, device_id: str, reading: Reading, value: float, at: datetime) -> float | None:
from .readings import COUNTER_UNITS
key = (device_id, reading.key)
prev = self._last.get(key)
self._last[key] = (value, at)
if prev is None:
return None
seconds = (at - prev[1]).total_seconds()
if seconds <= 0 or value < prev[0]:
return None
return (value - prev[0]) / seconds * COUNTER_UNITS[reading.unit][1]
def forget(self, device_id: str) -> None:
for key in [k for k in self._last if k[0] == device_id]:
del self._last[key]
+22
View File
@@ -0,0 +1,22 @@
{# What Test found (milestone 491): who the device says it is and its
interfaces with their numbers, or why it didn't answer. #}
{% if found.error %}
<p class="tone-crit" role="alert" style="margin:0.75rem 0 0;">{{ found.error }}</p>
{% else %}
<div style="margin-top:0.75rem;">
<p class="tone-ok" style="margin:0 0 0.4rem;">{{ address }} answered.</p>
<dl class="test-facts" style="display:grid;grid-template-columns:max-content 1fr;gap:0.2rem 0.9rem;margin:0;font-size:0.85rem;">
<dt class="tone-dim">Name</dt><dd style="margin:0;">{{ found.name or "—" }}</dd>
<dt class="tone-dim">Description</dt><dd style="margin:0;overflow-wrap:anywhere;">{{ found.description or "—" }}</dd>
<dt class="tone-dim">Up for</dt><dd style="margin:0;">{{ (found.uptime_cs / 100)|duration if found.uptime_cs is not none else "—" }}</dd>
</dl>
{% if found.interfaces %}
<p class="field-hint" style="margin:0.6rem 0 0.3rem;">Interfaces, by the number to put in Interface traffic:</p>
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(11rem,1fr));gap:0.15rem 0.9rem;font-size:0.82rem;">
{% for number, name in found.interfaces %}<span><span class="mono tone-dim">{{ number }}</span> {{ name }}</span>{% endfor %}
</div>
{% else %}
<p class="field-hint" style="margin:0.6rem 0 0;">It lists no interfaces.</p>
{% endif %}
</div>
{% endif %}
+71 -70
View File
@@ -1,82 +1,83 @@
{# plugins/snmp/templates/snmp/device.html #}
{# plugins/snmp/templates/snmp/device.html — one SNMP device (milestone 491):
what its newest poll found, then a small chart per reading in that
reading's own unit, on a dated axis. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs %}
{% block title %}SNMP — {{ device_name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device_name, "")]) }}{% endblock %}
{% from "_macros.html" import crumbs, page_header, status_badge %}
{% block title %}SNMP — {{ device.name }} — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("SNMP", "/plugins/snmp/"), (device.name, "")]) }}{% endblock %}
{% block content %}
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
<h1 class="page-title" style="margin:0;">{{ device_name }}</h1>
<span style="font-size:0.8rem;color:var(--text-dim);">{{ device.host }}</span>
<div style="margin-left:auto;display:flex;gap:0.5rem;">
{% for h in [1, 6, 24, 168] %}
<a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>
{% if h < 24 %}{{ h }}h{% elif h == 24 %}24h{% else %}7d{% endif %}
</a>
{% endfor %}
</div>
</div>
{% call page_header(device.name, device.address ~ ((":" ~ device.port) if device.port != 161 else "") ~ " · SNMP v" ~ device.version) %}
{% for h in ranges %}
<a href="?hours={{ h }}" class="range-btn{% if hours == h %} active{% endif %}"{% if hours == h %} aria-current="true"{% endif %}>{% if h < 168 %}{{ h }}h{% else %}7d{% endif %}</a>
{% endfor %}
{% if session.user_role == 'admin' %}<a href="/plugins/snmp/devices/{{ device.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>{% endif %}
{% endcall %}
{% if oid_labels %}
<div class="card">
<canvas id="snmp-chart" style="width:100%;max-height:320px;"></canvas>
<p class="page-verdict">
{% if device.reach == 'up' %}{{ status_badge('up', 'Answering') }} polled {{ device.polled_at|ago }}
{% elif device.reach == 'down' %}{{ status_badge('down', 'Not answering') }} <span class="tone-crit">{{ device.says }}</span>
{% if device.ok_at %}Last answered {{ device.ok_at|ago }}.{% else %}It has never answered.{% endif %}
{% elif device.reach == 'stale' %}{{ status_badge('stale', 'Not polled') }} {{ device.says }}
{% else %}{{ status_badge('pending', 'Not polled yet') }} The first poll runs within a minute of saving.{% endif %}
</p>
{% if device.missing %}<p class="field-hint">Not answered by this device: {{ device.missing|join(", ") }}.</p>{% endif %}
{% if charts %}
<div class="node-grid">
{% for c in charts %}
<section class="card" aria-labelledby="snmp-chart-{{ loop.index }}" style="margin:0;">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:0.75rem;">
<h2 id="snmp-chart-{{ loop.index }}" class="section-title" style="margin:0;">{{ c.name }}</h2>
<span class="stat-tile-value" style="font-size:1rem;">{{ c.now }}</span>
</div>
<div style="position:relative;height:140px;margin-top:0.5rem;">
<canvas id="snmp-c-{{ loop.index0 }}" role="img" aria-label="{{ c.name }} over the last {{ hours }} hours"></canvas>
</div>
</section>
{% endfor %}
</div>
{% else %}
<div class="card field-hint">This device has no readings chosen{% if session.user_role == 'admin' %}; <a href="/plugins/snmp/devices/{{ device.id }}/edit">Edit</a> to choose some{% endif %}.</div>
{% endif %}
<script>
(function(){
const raw = {{ history_json | safe }};
const labels = {{ oid_labels | tojson }};
// Series colours from the tokens, in an order that keeps neighbours apart.
const palette = ["--blue", "--yellow", "--green", "--red", "--gold", "--orange", "--text-muted"].map(stewardColor);
// Collect all unique timestamps, sorted
const tsSet = new Set();
for (const pts of Object.values(raw)) {
for (const [ts] of pts) tsSet.add(ts);
(function () {
var charts = {{ charts_json | safe }};
var hours = {{ hours }};
var line = stewardColor("--blue");
function stamp(iso) {
var d = new Date(iso);
return hours > 24
? d.toLocaleDateString([], { weekday: "short", day: "numeric" }) + " " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false })
: d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
const allTs = Array.from(tsSet).sort();
if (allTs.length < 2) {
document.getElementById("snmp-chart").parentElement.innerHTML =
'<p style="color:var(--text-muted);padding:1rem;">Not enough data for a chart yet.</p>';
return;
}
const datasets = [];
labels.forEach((label, i) => {
const pts = raw[label] || [];
const map = Object.fromEntries(pts.map(([ts, v]) => [ts, v]));
datasets.push({
label,
data: allTs.map(ts => map[ts] ?? null),
borderColor: palette[i % palette.length],
backgroundColor: "transparent",
borderWidth: 1.5,
pointRadius: 0,
tension: 0.2,
spanGaps: true,
charts.forEach(function (c, i) {
var el = document.getElementById("snmp-c-" + i);
if (!el) return;
if (c.points.length < 2) {
el.parentElement.innerHTML = '<p class="field-hint" style="margin:0;">Not enough readings yet for a chart.</p>';
return;
}
new Chart(el.getContext("2d"), {
type: "line",
data: { labels: c.points.map(function (p) { return p[0]; }),
datasets: [{ data: c.points.map(function (p) { return p[1]; }), borderColor: line,
backgroundColor: "transparent", borderWidth: 1.5, pointRadius: 0, tension: 0.2 }] },
options: {
responsive: true, maintainAspectRatio: false, animation: false,
interaction: { mode: "index", intersect: false },
plugins: { legend: { display: false },
tooltip: { callbacks: { title: function (items) {
return items.length ? new Date(c.points[items[0].dataIndex][0]).toLocaleString([], { hour12: false }) : ""; } } } },
scales: {
x: { ticks: { maxTicksLimit: 6, maxRotation: 0, font: { size: 10 },
callback: function (v) { return stamp(c.points[v][0]); } }, grid: { display: false } },
y: { ticks: { font: { size: 10 }, maxTicksLimit: 4 },
title: { display: !!c.axis, text: c.axis, font: { size: 9 } } }
}
}
});
});
const ctx = document.getElementById("snmp-chart").getContext("2d");
new Chart(ctx, {
type: "line",
data: { labels: allTs.map(ts => ts.substring(11, 16)), datasets },
options: {
responsive: true,
interaction: { mode: "index", intersect: false },
plugins: {
legend: { position: "top", labels: { boxWidth: 12 } },
},
scales: {
x: { ticks: { maxTicksLimit: 12 } },
},
},
});
})();
</script>
{% else %}
<div class="card" style="color:var(--text-muted);text-align:center;padding:2rem;">
No OIDs configured for this device.
</div>
{% endif %}
{% endblock %}
+166
View File
@@ -0,0 +1,166 @@
{# plugins/snmp/templates/snmp/form.html — add or edit an SNMP device
(milestone 491): where it is, how to sign in, what to read, and Test to
ask the device before saving. Secrets are never sent back to the browser;
a blank secret field keeps the stored one. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}{{ ("Edit " ~ device.name) if device else "Add SNMP device" }} — Steward{% endblock %}
{% block breadcrumb %}
{%- if device -%}
{{ crumbs([("SNMP", "/plugins/snmp/"), (device.name, "/plugins/snmp/devices/" ~ device.id), ("Edit", "")]) }}
{%- else -%}
{{ crumbs([("SNMP", "/plugins/snmp/"), ("Add device", "")]) }}
{%- endif -%}
{% endblock %}
{% block content %}
{{ page_header(("Edit " ~ device.name) if device else "Add SNMP device") }}
<div class="form-page">
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<form method="post" id="snmp-form" action="{{ '/plugins/snmp/devices/' ~ device.id ~ '/edit' if device else '/plugins/snmp/devices/new' }}">{{ csrf_field() }}
{% if device %}<input type="hidden" name="device_id" value="{{ device.id }}">{% endif %}
<section class="card form-section" aria-labelledby="snmp-where">
<h2 id="snmp-where" class="section-title">Device</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="snmp-name">Name</label>
<input class="input" id="snmp-name" name="name" required maxlength="100" value="{{ v.name or '' }}" placeholder="core-switch" autocomplete="off">
</div>
<div class="field">
<label class="field-label" for="snmp-address">Address</label>
<input class="input mono" id="snmp-address" name="address" required value="{{ v.address or '' }}" placeholder="192.168.1.2 or switch.lan" autocomplete="off">
</div>
<div class="field">
<label class="field-label" for="snmp-port">Port</label>
<input class="input" id="snmp-port" name="port" type="number" min="1" max="65535" value="{{ v.port or 161 }}">
</div>
<div class="field">
<label class="field-label" for="snmp-host">Show on host</label>
<select class="input" id="snmp-host" name="host_id">
<option value="">The host with this address, if any</option>
{% for h in hosts %}<option value="{{ h.id }}"{% if v.host_id == h.id %} selected{% endif %}>{{ h.name }}</option>{% endfor %}
</select>
<span class="field-hint">Which host's page shows this device's readings.</span>
</div>
</div>
</section>
<section class="card form-section" aria-labelledby="snmp-auth">
<h2 id="snmp-auth" class="section-title">Sign-in</h2>
<div class="form-pair">
<div class="field">
<label class="field-label" for="snmp-version">SNMP version</label>
<select class="input" id="snmp-version" name="version">
{% for ver, words in [("2c", "v2c: a community string (most devices)"), ("3", "v3: a user with authentication and encryption"), ("1", "v1: a community string (old devices)")] %}
<option value="{{ ver }}"{% if v.version == ver %} selected{% endif %}>{{ words }}</option>
{% endfor %}
</select>
</div>
<div class="field" data-snmp="community">
<label class="field-label" for="snmp-community">Community</label>
<input class="input" id="snmp-community" name="community" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_community else 'public' }}">
<span class="field-hint">The device's read community. Blank {{ "keeps the saved one" if v.has_community else "means public" }}.</span>
</div>
</div>
<div class="form-pair" data-snmp="v3">
<div class="field">
<label class="field-label" for="snmp-user">User</label>
<input class="input" id="snmp-user" name="v3_user" value="{{ v.v3_user or '' }}" autocomplete="off">
</div>
<div class="field"></div>
<div class="field">
<label class="field-label" for="snmp-auth-proto">Authentication</label>
<select class="input" id="snmp-auth-proto" name="v3_auth">
{% for val, words in [("sha", "SHA-1"), ("sha256", "SHA-256"), ("sha512", "SHA-512"), ("sha224", "SHA-224"), ("sha384", "SHA-384"), ("md5", "MD5"), ("none", "None")] %}
<option value="{{ val }}"{% if v.v3_auth == val %} selected{% endif %}>{{ words }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field-label" for="snmp-auth-key">Authentication key</label>
<input class="input" id="snmp-auth-key" name="v3_auth_key" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_auth_key else 'at least 8 characters' }}">
</div>
<div class="field">
<label class="field-label" for="snmp-priv-proto">Encryption</label>
<select class="input" id="snmp-priv-proto" name="v3_priv">
{% for val, words in [("aes128", "AES-128"), ("aes192", "AES-192"), ("aes256", "AES-256"), ("des", "DES"), ("3des", "3DES"), ("none", "None")] %}
<option value="{{ val }}"{% if v.v3_priv == val %} selected{% endif %}>{{ words }}</option>
{% endfor %}
</select>
</div>
<div class="field">
<label class="field-label" for="snmp-priv-key">Encryption key</label>
<input class="input" id="snmp-priv-key" name="v3_priv_key" type="password" autocomplete="new-password"
placeholder="{{ 'kept; type to change' if v.has_priv_key else 'at least 8 characters' }}">
</div>
</div>
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
<button type="button" class="btn btn-sm btn-ghost"
hx-post="/plugins/snmp/devices/test" hx-include="#snmp-form" hx-target="#snmp-test"
hx-indicator="#snmp-testing">Test</button>
<span id="snmp-testing" class="field-hint htmx-indicator">Asking the device…</span>
<span class="field-hint">Asks the device for its name, uptime and interfaces, with what's typed here. Nothing is saved.</span>
</div>
<div id="snmp-test" aria-live="polite"></div>
</section>
<section class="card form-section" aria-labelledby="snmp-what">
<h2 id="snmp-what" class="section-title">Readings</h2>
<div class="check-list">
{% for pid, label, what in presets %}
<label style="display:flex;gap:0.5rem;align-items:flex-start;cursor:pointer;">
<input type="checkbox" name="presets" value="{{ pid }}"{% if pid in (v.presets or []) %} checked{% endif %} style="margin-top:0.2rem;">
<span><strong>{{ label }}</strong> <span class="field-hint">{{ what }}</span></span>
</label>
{% if pid == "interfaces" %}
<div class="field" style="margin:0 0 0.4rem 1.6rem;">
<label class="field-label" for="snmp-interfaces">Interface numbers</label>
<input class="input" id="snmp-interfaces" name="interfaces" value="{{ v.interfaces or '' }}" placeholder="1, 2, 5-8" autocomplete="off" style="max-width:20rem;">
</div>
{% endif %}
{% endfor %}
</div>
<div class="field" style="margin-top:0.9rem;">
<label class="field-label" for="snmp-custom">Custom readings</label>
<textarea class="input mono" id="snmp-custom" name="custom" rows="4" placeholder="1.3.6.1.4.1.318.1.1.1.2.2.2.0 battery_temperature celsius">{{ v.custom or '' }}</textarea>
<span class="field-hint">One a line: the OID, a name (letters, digits, underscores), and optionally a unit:
{% for u, words in units.items() %}<span class="mono">{{ u }}</span> ({{ words }}){{ ", " if not loop.last else "." }}{% endfor %}</span>
</div>
</section>
<div class="form-actions">
<button type="submit" class="btn">{{ "Save" if device else "Add device" }}</button>
<a href="{{ '/plugins/snmp/devices/' ~ device.id if device else '/plugins/snmp/' }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% if device %}
<section class="card danger-zone" aria-labelledby="snmp-delete">
<div>
<h2 id="snmp-delete" class="section-title">Delete this device</h2>
<p class="field-hint">Steward stops polling it. Its past readings age out with the rest of the history.</p>
</div>
<form method="post" action="/plugins/snmp/devices/{{ device.id }}/delete"
data-confirm="Delete SNMP device {{ device.name }}? Steward stops polling it.">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete device</button>
</form>
</section>
{% endif %}
</div>
<script>
(function () {
var version = document.getElementById("snmp-version");
function show() {
var v3 = version.value === "3";
document.querySelectorAll('[data-snmp="v3"]').forEach(function (el) { el.hidden = !v3; });
document.querySelectorAll('[data-snmp="community"]').forEach(function (el) { el.hidden = v3; });
}
version.addEventListener("change", show);
show();
})();
</script>
{% endblock %}
+17 -42
View File
@@ -1,52 +1,27 @@
{# Per-host SNMP fragment — embedded on the Hosts hub via HTMX. Self-contained.
Shows the SNMP device(s) whose address maps to this host + latest readings. #}
{# plugins/snmp/templates/snmp/host_panel.html — the SNMP panel on a host's
page: the devices bound to this host, or at its address, with their
readings in their units, or why they aren't answering. #}
{% from "_macros.html" import status_badge, stat_tile %}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;margin-bottom:0.6rem;gap:1rem;flex-wrap:wrap;">
<h3 class="section-title" style="margin-bottom:0;">SNMP</h3>
<a href="/plugins/snmp/" style="font-size:0.8rem;color:var(--text-muted);">All devices →</a>
<a href="/plugins/snmp/" class="field-hint">All devices</a>
</div>
{% for device in devices %}
<div style="{% if not loop.last %}margin-bottom:1rem;{% endif %}">
{% for d in devices %}
<div{% if not loop.last %} style="margin-bottom:1rem;"{% endif %}>
<div style="display:flex;align-items:center;gap:0.6rem;margin-bottom:0.5rem;flex-wrap:wrap;">
<span style="font-weight:500;font-size:0.92rem;">{{ device.name }}</span>
<span style="font-size:0.78rem;color:var(--text-dim);font-family:ui-monospace,monospace;">{{ device.host }}</span>
{% if device.bound %}
<span title="Explicitly bound to this host (host_id / steward_host)"
style="font-size:0.68rem;color:var(--text-muted);background:var(--bg-elevated);border:1px solid var(--border);border-radius:3px;padding:0.05rem 0.4rem;">bound</span>
{% endif %}
{% if device.reach == 'up' %}
<span style="font-size:0.74rem;" class="tone-ok">&#9679; reachable · read {{ device.at|ago }}</span>
{% elif device.reach == 'stale' %}
<span style="font-size:0.74rem;" class="tone-warn" title="The values below are from then">&#9679; no answer since {{ device.at|ago }}</span>
{% else %}
<span style="font-size:0.74rem;color:var(--text-dim);">&#9675; no data yet</span>
{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-ghost btn-sm"
style="margin-left:auto;font-size:0.76rem;padding:0.15rem 0.55rem;">History</a>
<a href="/plugins/snmp/devices/{{ d.id }}" style="font-weight:500;">{{ d.name }}</a>
<span class="field-hint mono">{{ d.address }}</span>
{% if d.reach == 'up' %}{{ status_badge('up', 'Answering') }}
{% elif d.reach == 'down' %}{{ status_badge('down', 'Not answering', title=d.says) }}
{% elif d.reach == 'stale' %}{{ status_badge('stale', 'Not polled', title=d.says) }}
{% else %}{{ status_badge('pending', 'Not polled yet') }}{% endif %}
</div>
{% if device.readings %}
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:0.5rem;">
{% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %}
<div style="background:var(--bg-elevated);border-radius:5px;padding:0.5rem 0.7rem;border:1px solid var(--border);">
<div style="font-size:0.68rem;color:var(--text-dim);margin-bottom:0.2rem;font-family:ui-monospace,monospace;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">{{ label }}</div>
{% if val is not none %}
<div style="font-size:1rem;font-weight:500;font-variant-numeric:tabular-nums;">
{% if val >= 1000000 %}{{ "%.2f"|format(val / 1000000) }}<span style="font-size:0.72rem;color:var(--text-muted);">M</span>
{% elif val >= 1000 %}{{ "%.1f"|format(val / 1000) }}<span style="font-size:0.72rem;color:var(--text-muted);">K</span>
{% else %}{{ "%.4g"|format(val) }}{% endif %}
</div>
{% else %}
<div style="font-size:0.85rem;color:var(--text-dim);">—</div>
{% endif %}
</div>
{% endfor %}
{% if d.reach == 'down' %}<p class="field-hint" style="margin:0 0 0.5rem;">{{ d.says }}</p>{% endif %}
{% if d.reach != 'pending' and d.readings %}
<div class="stat-strip" style="margin-bottom:0;">
{% for r in d.readings %}{{ stat_tile(r.name, r.text) }}{% endfor %}
</div>
{% else %}
<p style="font-size:0.8rem;color:var(--text-dim);margin:0;">No readings yet — waiting for the next poll.</p>
{% endif %}
</div>
{% endfor %}
+39 -29
View File
@@ -1,45 +1,55 @@
{# plugins/snmp/templates/snmp/index.html #}
{# plugins/snmp/templates/snmp/index.html — every SNMP device, those not
answering first, each saying what its newest poll found: its readings in
their units, or why it didn't answer (milestone 491). #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}SNMP — Steward{% endblock %}
{% block content %}
{{ page_header("SNMP devices", devices|length ~ " device" ~ ("s" if devices|length != 1 else "") ~ " configured") }}
{% set admin = session.user_role == 'admin' %}
{% call page_header("SNMP devices", (devices|length ~ " device" ~ ("s" if devices|length != 1 else "")) if devices else none) %}
{% if admin %}<a class="btn btn-sm" href="/plugins/snmp/devices/new">Add device</a>{% endif %}
{% endcall %}
{% if not devices %}
<div class="card">{{ empty_state("No SNMP devices configured. Add them to the snmp plugin's plugin.yaml.") }}</div>
{% else %}
<div class="card">{{ empty_state("No SNMP devices yet. Add a switch, a UPS, a printer or a server that answers SNMP, and Steward polls it every minute.",
"Add device" if admin else none, "/plugins/snmp/devices/new") }}</div>
{% endif %}
{% for device in devices %}
<section class="card" aria-labelledby="snmp-{{ loop.index }}">
{% for d in devices %}
<section class="card" aria-labelledby="snmp-{{ d.id }}">
<div class="host-health-head">
{#- reach: answered within the last few polls (up), only long ago (stale),
or never (pending); routes._device_state. #}
{% if device.reach == 'up' %}{{ status_badge('up', 'Reachable') }}
{% elif device.reach == 'stale' %}{{ status_badge('stale', 'No answer', title="The last reading is from " ~ device.at.strftime('%Y-%m-%d %H:%M UTC') ~ "; the values below are from then") }}
{% else %}{{ status_badge('pending', 'No data yet') }}{% endif %}
<h2 id="snmp-{{ loop.index }}" class="section-title" style="margin:0;">{{ device.name }}</h2>
<span class="field-hint mono">{{ device.host }}</span>
{% if device.at %}<span class="field-hint">read {{ device.at|ago }}</span>{% endif %}
<a href="/plugins/snmp/device/{{ device.name }}" class="btn btn-sm btn-ghost" style="margin-left:auto;">History</a>
{% if d.reach == 'up' %}{{ status_badge('up', 'Answering') }}
{% elif d.reach == 'down' %}{{ status_badge('down', 'Not answering') }}
{% elif d.reach == 'stale' %}{{ status_badge('stale', 'Not polled') }}
{% else %}{{ status_badge('pending', 'Not polled yet') }}{% endif %}
<h2 id="snmp-{{ d.id }}" class="section-title" style="margin:0;"><a href="/plugins/snmp/devices/{{ d.id }}">{{ d.name }}</a></h2>
<span class="field-hint mono">{{ d.address }}{% if d.port != 161 %}:{{ d.port }}{% endif %}</span>
<span class="field-hint">SNMP v{{ d.version }}</span>
<span class="page-actions" style="margin-left:auto;">
<a href="/plugins/snmp/devices/{{ d.id }}" class="btn btn-sm btn-ghost">History</a>
{% if admin %}<a href="/plugins/snmp/devices/{{ d.id }}/edit" class="btn btn-sm btn-ghost">Edit</a>{% endif %}
</span>
</div>
{% if device.readings %}
{% if d.reach == 'down' %}
<p class="tone-crit" style="margin:0 0 0.4rem;">{{ d.says }}</p>
<p class="field-hint" style="margin:0 0 0.6rem;">
{%- if d.ok_at %}Last answered {{ d.ok_at|ago }}; the readings below are from then.{% else %}It has never answered.{% endif %}
Tried {{ d.polled_at|ago }}.</p>
{% elif d.reach == 'stale' %}
<p class="field-hint" style="margin:0 0 0.6rem;">{{ d.says }} Last polled {{ d.polled_at|ago }}.</p>
{% elif d.reach == 'pending' %}
<p class="field-hint" style="margin:0 0 0.6rem;">Not polled yet: the first poll runs within a minute of saving.</p>
{% endif %}
{% if d.reach != 'pending' and d.readings %}
<div class="stat-strip" style="margin-bottom:0;">
{% for oid_cfg in device.oids %}
{% set label = oid_cfg.label if oid_cfg.label else oid_cfg.oid %}
{% set val = device.readings.get(label) %}
{{ stat_tile(label,
"—" if val is none else
("%.2fM"|format(val / 1000000)) if val >= 1000000 else
("%.1fK"|format(val / 1000)) if val >= 1000 else
"%.4g"|format(val)) }}
{% endfor %}
{% for r in d.readings %}{{ stat_tile(r.name, r.text, hint="from the second poll" if r.counter and r.text == "—" else none) }}{% endfor %}
</div>
{% else %}
<p class="field-hint">No readings yet; waiting for the next poll.</p>
{% endif %}
{% if d.missing %}
<p class="field-hint" style="margin:0.6rem 0 0;">Not answered by this device: {{ d.missing|join(", ") }}. It may not support them; Edit to remove them.</p>
{% endif %}
</section>
{% endfor %}
{% endif %}
{% endblock %}
+17 -16
View File
@@ -1,15 +1,15 @@
{# 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. #}
{# plugins/snmp/templates/snmp/widget.html — the dashboard SNMP widget (#5255,
milestone 491): a verdict, devices not answering first with why, then the
rest, each with two readings in their units. The 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>
<div class="w-verdict" style="color:var(--text-muted);">No SNMP devices yet.{% if session.user_role == 'admin' %} <a href="/plugins/snmp/devices/new">Add one</a>{% endif %}</div>
{% else %}
{% 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.
{% set down = devices | selectattr("reach", "equalto", "down") | list %}
<div class="w-verdict {{ 'tone-crit' if counts.down else ('tone-warn' if counts.stale or counts.pending else 'tone-ok') }}">
{%- if counts.down == 1 and down %}{{ down[0].name }} isn't answering.
{%- elif counts.down %}{{ counts.down }} of {{ total }} devices not answering.
{%- elif counts.stale %}{{ counts.stale }} not polled recently.
{%- elif counts.pending %}{{ counts.pending }} not polled yet, {{ counts.up }} answering.
{%- else %}All {{ total }} device{{ 's' if total != 1 }} answering.{% endif -%}
</div>
@@ -20,15 +20,16 @@
{% 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 %}
{{- status_dot({'down': 'down', 'stale': 'stale', 'pending': 'pending'}.get(d.reach, 'up')) -}}
{% if session.user_id %}<a href="/plugins/snmp/devices/{{ d.id }}">{{ d.name }}</a>{% else %}{{ d.name }}{% endif %}
{%- if d.reach == 'down' %}<span class="w-sub tone-crit" title="{{ d.says }}">{% if d.ok_at %}no reply since {{ d.ok_at|ago }}{% else %}never answered{% endif %}</span>
{%- elif d.reach == 'stale' %}<span class="w-sub">not polled since {{ d.polled_at|ago }}</span>
{%- elif d.reach == 'pending' %}<span class="w-sub">not polled 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 title="{% for r in d.key %}{{ r.name }} {{ r.text }}{% if not loop.last %} · {% endif %}{% endfor %}">
{%- for r in d.key %}<span style="color:var(--text-muted);">{{ r.name }}</span> {{ r.text }}{% if not loop.last %} · {% endif %}{% else %}—{% endfor -%}
</td>
<td class="w-wide-only">{% if d.at %}{{ d.at|ago }}{% else %}—{% endif %}</td>
<td class="w-wide-only">{% if d.polled_at %}{{ d.polled_at|ago }}{% else %}—{% endif %}</td>
</tr>
{% endfor %}
</tbody>