A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.
A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.
- Synology: system, power and fan status and temperature; per disk: status,
health, temperature and bad sectors; per volume: RAID state (scrubbing,
parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
space and size; CPU and system temperature, free memory and fans.
From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).
Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
"""SNMP devices keep the rows of their vendor tables, and states in words
|
||||
|
||||
A NAS preset reads a row per disk and volume, found by walking the device's
|
||||
tables; the rows found, when, and each state reading's newest word are kept
|
||||
on the device (milestone 530).
|
||||
|
||||
Revision ID: snmp_002_table_rows
|
||||
Revises: snmp_001_devices
|
||||
Create Date: 2026-10-17
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "snmp_002_table_rows"
|
||||
down_revision: Union[str, None] = "snmp_001_devices"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("snmp_devices", sa.Column("tables", sa.JSON, nullable=False, server_default="{}"))
|
||||
op.add_column("snmp_devices", sa.Column("tables_at", sa.DateTime(timezone=True), nullable=True))
|
||||
op.add_column("snmp_devices", sa.Column("texts", sa.JSON, nullable=False, server_default="{}"))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("snmp_devices", "texts")
|
||||
op.drop_column("snmp_devices", "tables_at")
|
||||
op.drop_column("snmp_devices", "tables")
|
||||
@@ -16,6 +16,9 @@ class SnmpDevice(Base):
|
||||
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.
|
||||
`tables` is the rows the poller found in the presets' vendor tables (a
|
||||
disk or volume each), as of `tables_at`; `texts` is each state reading's
|
||||
newest answer in the device's own word.
|
||||
"""
|
||||
__tablename__ = "snmp_devices"
|
||||
|
||||
@@ -40,3 +43,6 @@ class SnmpDevice(Base):
|
||||
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)
|
||||
tables: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
tables_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
texts: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||
|
||||
+98
-5
@@ -48,6 +48,13 @@ 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
|
||||
texts: dict[str, str] = field(default_factory=dict) # keys answered in words, not a number
|
||||
|
||||
|
||||
@dataclass
|
||||
class TableScan:
|
||||
rows: dict[str, list[dict]] = field(default_factory=dict) # table id -> [{"index", "label", "columns"}]
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def _pysnmp_available() -> bool:
|
||||
@@ -149,9 +156,10 @@ 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.
|
||||
Returns the numeric answers by key, the answers in words (a vendor's
|
||||
status), the keys the device answered without, 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:
|
||||
@@ -198,8 +206,12 @@ async def poll_device(address: str, port: int, creds: Credentials,
|
||||
try:
|
||||
result.readings[key] = float(val)
|
||||
except (TypeError, ValueError):
|
||||
result.missing.append(key)
|
||||
if not result.readings and result.missing:
|
||||
text = str(val).strip()
|
||||
if text:
|
||||
result.texts[key] = text
|
||||
else:
|
||||
result.missing.append(key)
|
||||
if not result.readings and not result.texts and result.missing:
|
||||
result.error = "The device answered but has none of these readings."
|
||||
return result
|
||||
except Exception as exc:
|
||||
@@ -210,6 +222,87 @@ async def poll_device(address: str, port: int, creds: Credentials,
|
||||
_close_engine(engine)
|
||||
|
||||
|
||||
# Rows read of one table: enough for any NAS's disks and volumes, and a bound
|
||||
# on a device that answers a walk forever.
|
||||
ROWS_PER_TABLE = 64
|
||||
|
||||
|
||||
async def _walk_column(api, engine, auth, transport, column: str) -> dict[str, object] | str:
|
||||
"""{row index: value} for one column, or the error in words when the
|
||||
device didn't answer. A column the device lacks is an empty dict."""
|
||||
found: dict[str, object] = {}
|
||||
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:
|
||||
return str(ei)
|
||||
if es:
|
||||
break
|
||||
for name, val in rows:
|
||||
oid = str(name)
|
||||
if type(val).__name__ in _ABSENT or not oid.startswith(column + "."):
|
||||
continue
|
||||
found[oid[len(column) + 1:]] = val
|
||||
if len(found) >= ROWS_PER_TABLE:
|
||||
break
|
||||
return found
|
||||
|
||||
|
||||
async def walk_tables(address: str, port: int, creds: Credentials, tables: list) -> TableScan:
|
||||
"""Which rows each vendor table has: each row's index, its label, and the
|
||||
columns it answered. A walk per column, so it is asked for by the
|
||||
scheduled poll when a device's rows are unknown or an hour old, never on a
|
||||
page load. Never raises."""
|
||||
scan = TableScan()
|
||||
if not tables:
|
||||
return scan
|
||||
if not _pysnmp_available():
|
||||
scan.error = "pysnmp isn't installed in this image, so SNMP polling is off."
|
||||
return scan
|
||||
api = _api()
|
||||
engine = api.hl.SnmpEngine()
|
||||
try:
|
||||
try:
|
||||
transport = await _transport(api, address, port)
|
||||
except Exception as exc:
|
||||
scan.error = f"Can't reach {address}:{port}: {exc}"
|
||||
return scan
|
||||
auth = _auth_data(api, creds)
|
||||
for table in tables:
|
||||
labels: dict[str, object] = {}
|
||||
if table.label:
|
||||
got = await _walk_column(api, engine, auth, transport, table.label)
|
||||
if isinstance(got, str):
|
||||
scan.error = explain(got, address, port, creds)
|
||||
return scan
|
||||
labels = got
|
||||
answered: dict[str, set[str]] = {}
|
||||
for column in table.columns:
|
||||
got = await _walk_column(api, engine, auth, transport, column.oid)
|
||||
if isinstance(got, str):
|
||||
scan.error = explain(got, address, port, creds)
|
||||
return scan
|
||||
for index in got:
|
||||
answered.setdefault(index, set()).add(column.key)
|
||||
indexes = labels if table.label else answered
|
||||
rows = []
|
||||
for index in sorted(indexes, key=lambda i: [int(p) for p in i.split(".") if p.isdigit()]):
|
||||
label = str(labels.get(index, "")).strip()
|
||||
if table.label and not table.keeps(label):
|
||||
continue
|
||||
columns = [c.key for c in table.columns if c.key in answered.get(index, ())]
|
||||
if columns:
|
||||
rows.append({"index": index, "label": label, "columns": columns})
|
||||
scan.rows[table.id] = rows[:ROWS_PER_TABLE]
|
||||
return scan
|
||||
except Exception as exc:
|
||||
logger.warning("SNMP table walk of %s:%s failed: %s", address, port, exc)
|
||||
scan.error = f"SNMP table walk failed: {exc}"
|
||||
return scan
|
||||
finally:
|
||||
_close_engine(engine)
|
||||
|
||||
|
||||
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
|
||||
|
||||
+207
-7
@@ -6,6 +6,14 @@ 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.
|
||||
|
||||
A preset may also read a vendor's tables, a row per disk or volume (Table).
|
||||
Which rows a device has is discovered from the device by the scheduled poll
|
||||
and stored on its row, so every reader builds the same readings from it.
|
||||
|
||||
A vendor's status (a code, or a word) is a "state" reading: stored as a level
|
||||
(0 OK, 1 busy, 2 warning, 3 failed), so one alert rule reads any vendor's,
|
||||
and shown in the device's own word.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -23,6 +31,7 @@ class Reading:
|
||||
unit: str = "number"
|
||||
scale: float = 1.0
|
||||
offset: float = 0.0
|
||||
states: str = "" # a state reading's codes: a STATE_CODES name, or "words"
|
||||
|
||||
@property
|
||||
def counter(self) -> bool:
|
||||
@@ -39,6 +48,49 @@ STATUS_WORDS = {
|
||||
"ups_battery": {1: "unknown", 2: "normal", 3: "low", 4: "depleted"},
|
||||
}
|
||||
|
||||
# A state reading is stored as one of these levels; an alert rule on "level
|
||||
# at least 2" catches any vendor's degraded or failed.
|
||||
LEVELS = {0: "OK", 1: "Busy", 2: "Warning", 3: "Failed"}
|
||||
|
||||
# A vendor's status codes: code -> (the vendor's word, level). Synology's are
|
||||
# from its MIB guide (SYNOLOGY-SYSTEM-, -DISK- and -RAID-MIB).
|
||||
STATE_CODES: dict[str, dict[int, tuple[str, int]]] = {
|
||||
"synology_ok": {1: ("Normal", 0), 2: ("Failed", 3)},
|
||||
"synology_disk": {1: ("Normal", 0), 2: ("Initialized", 0), 3: ("Not initialized", 2),
|
||||
4: ("System partition failed", 3), 5: ("Crashed", 3)},
|
||||
"synology_health": {1: ("Normal", 0), 2: ("Warning", 2), 3: ("Critical", 3), 4: ("Failing", 3)},
|
||||
"synology_raid": {1: ("Normal", 0), 2: ("Repairing", 1), 3: ("Migrating", 1), 4: ("Expanding", 1),
|
||||
5: ("Deleting", 1), 6: ("Creating", 1), 7: ("Syncing", 1), 8: ("Parity checking", 1),
|
||||
9: ("Assembling", 1), 10: ("Canceling", 1), 11: ("Degraded", 2), 12: ("Crashed", 3),
|
||||
13: ("Data scrubbing", 1), 14: ("Deploying", 1), 15: ("Undeploying", 1),
|
||||
16: ("Mounting cache", 1), 17: ("Unmounting cache", 1), 18: ("Expanding", 1),
|
||||
19: ("Converting to a storage pool", 1), 20: ("Migrating SHR1 to SHR2", 1),
|
||||
21: ("Unknown", 2)},
|
||||
}
|
||||
|
||||
# A vendor that answers in words (Asustor) is read by the words' beginnings,
|
||||
# worst first: "Unhealthy" is failed before "healthy" can call it OK.
|
||||
_WORD_LEVELS = (
|
||||
(3, re.compile(r"\b(fail|crash|critical|bad|error|abnormal|unhealthy|broken|removed|missing|offline)", re.I)),
|
||||
(2, re.compile(r"\b(degrad|warn|unknown)", re.I)),
|
||||
(1, re.compile(r"\b(sync|resync|rebuild|repair|recover|scrub|check|migrat|expand|creat|initiali|build)",
|
||||
re.I)),
|
||||
(0, re.compile(r"\b(normal|good|healthy|ok|ready|active|online|clean|pass)", re.I)),
|
||||
)
|
||||
|
||||
|
||||
def state_of(reading: Reading, raw: float | str) -> tuple[str, int | None]:
|
||||
"""A state reading's answer as (the device's word, level). The level is
|
||||
None for a code or word Steward doesn't know: it is shown, not stored."""
|
||||
if reading.states == "words":
|
||||
word = str(raw).strip()
|
||||
return word, next((level for level, rx in _WORD_LEVELS if rx.search(word)), None)
|
||||
try:
|
||||
code = int(float(raw))
|
||||
except (TypeError, ValueError):
|
||||
return str(raw).strip(), None
|
||||
return STATE_CODES.get(reading.states, {}).get(code, (f"code {code}", None))
|
||||
|
||||
# The units a custom line may name, with how each one reads, for the form's hint.
|
||||
UNITS = {
|
||||
"number": "a plain number",
|
||||
@@ -51,12 +103,82 @@ UNITS = {
|
||||
"centiseconds": "a span in hundredths of a second (sysUpTime)",
|
||||
"bytes": "a size in bytes",
|
||||
"kilobytes": "a size in KiB",
|
||||
"rpm": "revolutions per minute (a fan)",
|
||||
"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")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Column:
|
||||
"""One column of a vendor table: a row's reading is this OID plus the
|
||||
row's index."""
|
||||
oid: str
|
||||
key: str
|
||||
name: str
|
||||
unit: str = "number"
|
||||
scale: float = 1.0
|
||||
states: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Table:
|
||||
"""A vendor table with a row per disk, volume, fan or sensor.
|
||||
|
||||
`label` is the column naming each row ("Disk 1", "Volume 1"); a row's
|
||||
readings are keyed by prefix, index and column ("disk0_status") and named
|
||||
by its label and column ("Disk 1 status"). A table with no label column
|
||||
(one temperature sensor, one memory) is keyed and named by its columns
|
||||
alone while it has one row. `rows_like` keeps only rows whose label
|
||||
matches it (physical disks, not partitions and loop devices)."""
|
||||
id: str
|
||||
prefix: str
|
||||
label: str
|
||||
noun: str
|
||||
columns: tuple[Column, ...]
|
||||
rows_like: str = ""
|
||||
|
||||
def column_oids(self) -> list[str]:
|
||||
return ([self.label] if self.label else []) + [c.oid for c in self.columns]
|
||||
|
||||
def keeps(self, label: str) -> bool:
|
||||
return not self.rows_like or re.fullmatch(self.rows_like, label) is not None
|
||||
|
||||
|
||||
def table_readings(table: Table, rows: list[dict]) -> list[Reading]:
|
||||
"""The readings of a table's discovered rows: [{"index", "label",
|
||||
"columns"}], each asked only for the columns it answered."""
|
||||
out: list[Reading] = []
|
||||
alone = not table.label and len(rows) == 1
|
||||
for row in rows:
|
||||
index, label = str(row["index"]), str(row.get("label") or "")
|
||||
if label.isdigit() or (table.label and not label):
|
||||
label = f"{table.noun} {label or index}"
|
||||
for c in table.columns:
|
||||
if c.key not in row.get("columns", ()):
|
||||
continue
|
||||
if table.label:
|
||||
key, name = f"{table.prefix}{index.replace('.', '_')}_{c.key}", f"{label} {c.name}"
|
||||
elif alone:
|
||||
key, name = c.key, c.name
|
||||
else:
|
||||
key, name = f"{c.key}{index.replace('.', '_')}", f"{c.name} ({table.noun.lower()} {index})"
|
||||
out.append(Reading(f"{c.oid}.{index}", key, name, c.unit, scale=c.scale, states=c.states))
|
||||
return out
|
||||
|
||||
|
||||
def _syn(oid: str) -> str:
|
||||
return f"1.3.6.1.4.1.6574.{oid}"
|
||||
|
||||
|
||||
def _asus(oid: str) -> str:
|
||||
return f"1.3.6.1.4.1.44738.{oid}"
|
||||
|
||||
|
||||
_GIB = float(2 ** 30)
|
||||
|
||||
# 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, ...]]] = {
|
||||
@@ -70,18 +192,72 @@ PRESETS: dict[str, tuple[str, str, tuple[Reading, ...]]] = {
|
||||
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", "battery_seconds"))),
|
||||
"linux": ("Linux server", "CPU, memory and load from net-snmp (snmpd's UCD-SNMP-MIB).",
|
||||
"linux": ("Linux server", "CPU, memory and load from net-snmp (snmpd's UCD-SNMP-MIB), and how busy "
|
||||
"each disk has been over the last minute (UCD-DISKIO-MIB). Most NAS boxes "
|
||||
"answer it too.",
|
||||
(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"))),
|
||||
"synology": ("Synology NAS", "System, power and fan status and temperature; each disk's status, health, "
|
||||
"temperature and bad sectors; each volume's state (scrubbing, rebuilding, "
|
||||
"degraded) and space. Tick Linux server too for CPU, memory and disk load.",
|
||||
(Reading(_syn("1.1.0"), "system_status", "System", "state", states="synology_ok"),
|
||||
Reading(_syn("1.2.0"), "temperature", "Temperature", "celsius"),
|
||||
Reading(_syn("1.3.0"), "power_status", "Power", "state", states="synology_ok"),
|
||||
Reading(_syn("1.4.1.0"), "system_fan", "System fan", "state", states="synology_ok"),
|
||||
Reading(_syn("1.4.2.0"), "cpu_fan", "CPU fan", "state", states="synology_ok"))),
|
||||
"asustor": ("Asustor NAS", "Each disk's status, SMART verdict and temperature; each volume's status and "
|
||||
"space; CPU and system temperature, free memory and fan speed (ADM's "
|
||||
"ASUSTOR-DISK- and -SYSTEM-MIB). Tick Linux server too for CPU, load and disk load.",
|
||||
()),
|
||||
}
|
||||
|
||||
PRESET_ORDER = ("system", "interfaces", "ups", "linux")
|
||||
# A preset's vendor tables, read as one row per disk, volume, fan or sensor.
|
||||
TABLES: dict[str, tuple[Table, ...]] = {
|
||||
"linux": (Table("ucd_diskio", "diskio", "1.3.6.1.4.1.2021.13.15.1.1.2", "Disk",
|
||||
(Column("1.3.6.1.4.1.2021.13.15.1.1.9", "busy", "busy", "percent"),),
|
||||
rows_like=r"(sd[a-z]+|hd[a-z]+|vd[a-z]+|xvd[a-z]+|nvme\d+n\d+|sata\d+)"),),
|
||||
"synology": (
|
||||
Table("synology_disks", "disk", _syn("2.1.1.2"), "Disk",
|
||||
(Column(_syn("2.1.1.5"), "status", "status", "state", states="synology_disk"),
|
||||
Column(_syn("2.1.1.13"), "health", "health", "state", states="synology_health"),
|
||||
Column(_syn("2.1.1.6"), "temp", "temperature", "celsius"),
|
||||
Column(_syn("2.1.1.9"), "bad_sectors", "bad sectors"))),
|
||||
Table("synology_volumes", "raid", _syn("3.1.1.2"), "Volume",
|
||||
(Column(_syn("3.1.1.3"), "status", "status", "state", states="synology_raid"),
|
||||
Column(_syn("3.1.1.4"), "free", "free", "bytes"),
|
||||
Column(_syn("3.1.1.5"), "size", "size", "bytes"))),
|
||||
),
|
||||
"asustor": (
|
||||
Table("asustor_disks", "disk", _asus("4.1.1.2"), "Disk",
|
||||
(Column(_asus("4.1.1.5"), "status", "status", "state", states="words"),
|
||||
Column(_asus("4.1.1.6"), "smart", "SMART", "state", states="words"),
|
||||
Column(_asus("4.1.1.7"), "temp", "temperature", "celsius"))),
|
||||
Table("asustor_volumes", "volume", _asus("5.1.1.2"), "Volume",
|
||||
(Column(_asus("5.1.1.4"), "status", "status", "state", states="words"),
|
||||
Column(_asus("5.1.1.7"), "free", "free", "bytes", scale=_GIB),
|
||||
Column(_asus("5.1.1.6"), "size", "size", "bytes", scale=_GIB))),
|
||||
Table("asustor_temperatures", "", "", "Sensor",
|
||||
(Column(_asus("2.5.1.2"), "cpu_temp", "CPU temperature", "celsius"),
|
||||
Column(_asus("2.5.1.3"), "system_temp", "System temperature", "celsius"))),
|
||||
Table("asustor_memory", "", "", "Memory",
|
||||
(Column(_asus("2.6.1.4"), "memory_free_pct", "Memory free", "percent"),)),
|
||||
Table("asustor_fans", "", "", "Fan",
|
||||
(Column(_asus("2.4.1.2"), "fan", "Fan speed", "rpm"),)),
|
||||
),
|
||||
}
|
||||
|
||||
PRESET_ORDER = ("system", "interfaces", "ups", "linux", "synology", "asustor")
|
||||
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 tables_for(presets: list[str]) -> list[Table]:
|
||||
"""The vendor tables the ticked presets read, in preset order."""
|
||||
return [t for pid in PRESET_ORDER if pid in (presets or []) for t in TABLES.get(pid, ())]
|
||||
|
||||
|
||||
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] = []
|
||||
@@ -142,9 +318,13 @@ def parse_custom(text: str) -> list[Reading]:
|
||||
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."""
|
||||
def device_readings(presets: list[str], interfaces: list[int], custom: str, version: str,
|
||||
tables: dict | None = None) -> list[Reading]:
|
||||
"""Every reading a device is asked for: presets in their order, the rows
|
||||
discovered in their tables (`tables`, {table id: rows}), then custom
|
||||
lines. Raises ValueError when a custom line repeats a preset's name; a
|
||||
table row a custom line already names is left to the custom line, since
|
||||
rows appear after the form was saved."""
|
||||
out: list[Reading] = []
|
||||
for pid in PRESET_ORDER:
|
||||
if pid == "interfaces":
|
||||
@@ -152,7 +332,14 @@ def device_readings(presets: list[str], interfaces: list[int], custom: str, vers
|
||||
out += interface_readings(interfaces, version)
|
||||
elif pid in presets:
|
||||
out += PRESETS[pid][2]
|
||||
out += parse_custom(custom)
|
||||
custom_readings = parse_custom(custom)
|
||||
taken = {r.key for r in out} | {r.key for r in custom_readings}
|
||||
for table in tables_for(presets):
|
||||
for r in table_readings(table, (tables or {}).get(table.id, [])):
|
||||
if r.key not in taken:
|
||||
out.append(r)
|
||||
taken.add(r.key)
|
||||
out += custom_readings
|
||||
seen: set[str] = set()
|
||||
for r in out:
|
||||
if r.key in seen:
|
||||
@@ -177,6 +364,8 @@ 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 == "state":
|
||||
return LEVELS.get(int(value), f"level {value:.0f}")
|
||||
if unit in STATUS_WORDS:
|
||||
return STATUS_WORDS[unit].get(int(value), f"code {value:.0f}")
|
||||
if unit == "centiseconds":
|
||||
@@ -204,6 +393,8 @@ def format_value(unit: str, value: float | None) -> str:
|
||||
return _bits(value)
|
||||
if unit == "per_second":
|
||||
return f"{value:.3g}/s"
|
||||
if unit == "rpm":
|
||||
return f"{value:,.0f} rpm"
|
||||
return f"{value:,.0f}" if float(value).is_integer() else f"{value:.4g}"
|
||||
|
||||
|
||||
@@ -214,4 +405,13 @@ def chart_axis(unit: str) -> tuple[str, float]:
|
||||
"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))
|
||||
"per_second": ("per second", 1.0), "rpm": ("rpm", 1.0),
|
||||
"state": ("0 OK · 1 busy · 2 warning · 3 failed", 1.0)}.get(unit, ("", 1.0))
|
||||
|
||||
|
||||
def reading_text(reading: Reading, value: float | None, texts: dict | None) -> str:
|
||||
"""What a page shows for a reading: a state in the device's own word, as
|
||||
its newest poll answered it; anything else in its unit."""
|
||||
if reading.unit == "state" and (texts or {}).get(reading.key):
|
||||
return texts[reading.key]
|
||||
return format_value(stored_unit(reading), value)
|
||||
|
||||
@@ -17,8 +17,7 @@ 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 .readings import INTERFACES_LABEL, PRESETS, PRESET_ORDER, UNITS, chart_axis, reading_text, stored_unit
|
||||
from .service import (FormError, device_state, form_credentials, form_values, parse_form,
|
||||
readings_for)
|
||||
|
||||
@@ -40,10 +39,10 @@ async def _latest(db, names: list[str]) -> dict[str, dict]:
|
||||
|
||||
|
||||
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."""
|
||||
"""The device's readings in its own order, each named and in its unit (a
|
||||
state in the device's own word); "—" 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))}
|
||||
"text": reading_text(r, values.get(r.key), device.texts)}
|
||||
for r in readings_for(device)]
|
||||
|
||||
|
||||
@@ -168,9 +167,10 @@ async def save_device(device_id: str):
|
||||
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 = []
|
||||
# longer describes it, so the page says "not polled yet" until the
|
||||
# next poll, which also walks its vendor tables again.
|
||||
device.last_polled_at = device.last_ok_at = device.last_error = device.tables_at = None
|
||||
device.missing, device.texts = [], {}
|
||||
if old_name != device.name:
|
||||
# Its history is stored under its name; move it with the rename.
|
||||
for model in (PluginMetric, PluginMetricLatest):
|
||||
@@ -263,7 +263,7 @@ async def device_detail(device_id: str):
|
||||
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)),
|
||||
"now": reading_text(r, latest.get(device.name, {}).get(r.key), device.texts),
|
||||
"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)
|
||||
|
||||
+56
-16
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
@@ -18,6 +18,20 @@ logger = logging.getLogger(__name__)
|
||||
# Counters' previous samples, for their rates (service.RateKeeper).
|
||||
RATES = None
|
||||
|
||||
# How long a device's discovered table rows stand before they are walked
|
||||
# again: a disk swapped or a volume added shows within the hour, and a NAS is
|
||||
# walked 24 times a day, not 1,440.
|
||||
TABLES_REFRESH = timedelta(hours=1)
|
||||
|
||||
|
||||
def tables_due(device, now: datetime) -> bool:
|
||||
"""Whether this poll walks the device's vendor tables: it reads some, it
|
||||
answered its last poll (a device that isn't answering is only asked its
|
||||
GET), and its rows are unknown, reset by a save, or an hour old."""
|
||||
from .readings import tables_for
|
||||
return (bool(tables_for(device.presets or [])) and not device.last_error
|
||||
and (device.tables_at is None or now - device.tables_at >= TABLES_REFRESH))
|
||||
|
||||
|
||||
def make_poll_task(app: "Quart") -> ScheduledTask:
|
||||
interval = app.config["PLUGINS"]["snmp"].get("poll_interval_seconds", 60)
|
||||
@@ -33,18 +47,21 @@ def make_poll_task(app: "Quart") -> ScheduledTask:
|
||||
)
|
||||
|
||||
|
||||
async def _do_poll(app: "Quart", poll=None) -> None:
|
||||
async def _do_poll(app: "Quart", poll=None, walk=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."""
|
||||
and its answers to the metric tables. A device whose vendor tables are
|
||||
due is walked first, so a new disk is read in the same poll. `poll` and
|
||||
`walk` stand in for poller.poll_device and poller.walk_tables in tests."""
|
||||
global RATES
|
||||
from steward.core.alerts import MetricBatch
|
||||
|
||||
from .models import SnmpDevice
|
||||
from .poller import poll_device
|
||||
from .poller import poll_device, walk_tables
|
||||
from .readings import state_of, tables_for
|
||||
from .service import RateKeeper, credentials, readings_for
|
||||
|
||||
poll = poll or poll_device
|
||||
walk = walk or walk_tables
|
||||
if RATES is None:
|
||||
RATES = RateKeeper()
|
||||
|
||||
@@ -53,10 +70,18 @@ async def _do_poll(app: "Quart", poll=None) -> None:
|
||||
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)
|
||||
started = datetime.now(timezone.utc)
|
||||
scans, asked = {}, {}
|
||||
|
||||
async def one(d):
|
||||
creds = credentials(d)
|
||||
if tables_due(d, started):
|
||||
scans[d.id] = await walk(d.address, d.port, creds, tables_for(d.presets or []))
|
||||
found = scans.get(d.id)
|
||||
asked[d.id] = readings_for(d, found.rows if found is not None and found.error is None else None)
|
||||
return await poll(d.address, d.port, creds, [(r.oid, r.key) for r in asked[d.id]])
|
||||
|
||||
results = await asyncio.gather(*(one(d) for d in devices), return_exceptions=True)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
async with app.db_sessionmaker() as session:
|
||||
@@ -71,20 +96,35 @@ async def _do_poll(app: "Quart", poll=None) -> None:
|
||||
logger.warning("SNMP poll of %s failed: %s", device.name, result)
|
||||
row.last_error = f"SNMP poll failed: {result}"
|
||||
continue
|
||||
scan = scans.get(device.id)
|
||||
if scan is not None and scan.error is None:
|
||||
row.tables, row.tables_at = scan.rows, now
|
||||
row.last_error = result.error
|
||||
row.missing = sorted(set(result.missing))
|
||||
if result.readings:
|
||||
missing = set(result.missing)
|
||||
if result.readings or result.texts:
|
||||
row.last_ok_at = now
|
||||
texts = dict(row.texts or {}) if result.error else {}
|
||||
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:
|
||||
if reading.unit == "state":
|
||||
# Its word is shown as answered; its level is stored,
|
||||
# unless Steward doesn't know the word (level None).
|
||||
raw = value if value is not None else result.texts.get(reading.key)
|
||||
if raw is None:
|
||||
continue
|
||||
texts[reading.key], value = state_of(reading, raw)
|
||||
elif value is None:
|
||||
if reading.key in result.texts: # a word where a number was asked for
|
||||
missing.add(reading.key)
|
||||
continue
|
||||
elif reading.counter:
|
||||
value = RATES.rate(device.id, reading, value, now)
|
||||
else:
|
||||
value = value * reading.scale + reading.offset
|
||||
if value is None:
|
||||
continue
|
||||
batch.add(source_module="snmp", resource_name=device.name,
|
||||
metric_name=reading.key, value=value, recorded_at=now)
|
||||
row.missing = sorted(missing)
|
||||
row.texts = texts
|
||||
await batch.record(session)
|
||||
|
||||
+11
-7
@@ -11,7 +11,7 @@ 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
|
||||
from .readings import PRESETS, Reading, device_readings, parse_interfaces, tables_for
|
||||
|
||||
VERSIONS = ("1", "2c", "3")
|
||||
PRESET_IDS = set(PRESETS) | {"interfaces"}
|
||||
@@ -22,13 +22,15 @@ 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."""
|
||||
def readings_for(device: SnmpDevice, tables: dict | None = None) -> list[Reading]:
|
||||
"""What the device is asked for, its vendor tables' rows included (the
|
||||
stored ones, or `tables` when the poll has just found them). 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)
|
||||
device.custom, device.version,
|
||||
device.tables if tables is None else tables)
|
||||
except ValueError:
|
||||
return []
|
||||
|
||||
@@ -84,7 +86,9 @@ def parse_form(form, existing: SnmpDevice | None = None) -> dict:
|
||||
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):
|
||||
# A NAS preset's rows are found by the first poll, so it reads
|
||||
# nothing until then and still counts as a choice.
|
||||
if not device_readings(presets, numbers, custom, version) and not tables_for(presets):
|
||||
raise FormError("Choose at least one preset or add a custom reading.")
|
||||
except ValueError as exc:
|
||||
raise FormError(str(exc)) from None
|
||||
|
||||
Reference in New Issue
Block a user