feat(snmp): a NAS's disk and volume I/O, CPU waiting on disk, available memory, interface errors (#5997)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m31s
CI / unit (push) Failing after 1m32s
CI / publish (push) Skipped

- Synology: what each disk and volume reads and writes, bytes and requests
  a second and the load DSM gives it (SYNOLOGY-STORAGEIO- and -SPACEIO-MIB,
  64-bit byte counters), and each disk's reconnects and sectors not found.
- Linux server: CPU waiting on disk, the wait tick counter's share of the
  CPU's, and memory available (free + buffers + cache). A reading may now be
  worked out from others: the poll asks for its inputs, rates each counter,
  and stores only the result.
- Interface traffic: errors and discards in and out, per second.
- byte_count unit: a byte counter shown as bytes a second.

Asustor's MIB has nothing more for this; its Linux server readings gain the
same CPU wait and available memory.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-17 09:50:52 -04:00
co-authored by Claude Opus 5.5
parent 3f9aae7f46
commit ad8bbd8d69
4 changed files with 198 additions and 27 deletions
+26 -8
View File
@@ -139,7 +139,7 @@ async def _do_poll(app: "Quart", poll=None, walk=None) -> None:
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]])
return await poll(d.address, d.port, creds, [(r.oid, r.key) for r in _asks(asked[d.id])])
results = await asyncio.gather(*(one(d) for d in devices), return_exceptions=True)
@@ -158,23 +158,35 @@ async def _do_poll(app: "Quart", poll=None, walk=None) -> None:
device.tables, device.tables_at = p.scan.rows, now
device.last_error = result.error
missing = set(result.missing)
for reading in asked[device.id]:
def read(reading):
value = result.readings.get(reading.key)
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
return None
p.texts[reading.key], value = state_of(reading, raw)
elif value is None:
return value
if 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)
return None
if reading.counter:
return RATES.rate(device.id, reading, value, now)
return value * reading.scale + reading.offset
for reading in asked[device.id]:
if reading.inputs:
# Every input is read, so each counter keeps its last sample.
got = {i.key: read(i) for i in reading.inputs}
if any(i.key in missing for i in reading.inputs):
missing -= {i.key for i in reading.inputs}
missing.add(reading.key)
value = None if None in got.values() else reading.combine(got)
else:
value = value * reading.scale + reading.offset
value = read(reading)
if value is not None:
p.values.append((reading.key, value))
p.missing = sorted(missing)
@@ -183,6 +195,12 @@ async def _do_poll(app: "Quart", poll=None, walk=None) -> None:
await held.flush(STORE_WAIT_S)
def _asks(readings: list) -> list:
"""What the device is asked for: each reading, or the inputs of one
worked out from others."""
return [i for r in readings for i in (r.inputs or (r,))]
async def _store(app: "Quart", rounds: list[list[Polled]]) -> None:
"""Write held polls, oldest first, in one transaction."""
from steward.core.alerts import MetricBatch