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:
@@ -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/{}")}
|
||||
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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">● 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">● no answer since {{ device.at|ago }}</span>
|
||||
{% else %}
|
||||
<span style="font-size:0.74rem;color:var(--text-dim);">○ 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user