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:
+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
|
||||
|
||||
Reference in New Issue
Block a user