feat(snmp): NAS presets for Synology and Asustor, reading every disk and volume (#5985, #5986)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m38s
CI / integration (push) Successful in 1m41s
CI / publish (push) Successful in 1m31s

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:
2026-10-16 22:40:14 -04:00
co-authored by Claude Opus 5.5
parent 4ded8ecee4
commit d065cafd11
12 changed files with 593 additions and 48 deletions
+9 -9
View File
@@ -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)