Persist endpoint disk health

Adds diskhealthjson with its migration in the same change, so the model never references a column the schema has not applied.

Kept separate from disksjson rather than merged into it. That column is per volume and answers how full a drive is; this one is per physical device and answers whether the hardware is dying. They come from different WMI sources with different failure modes, so merging them would make a capacity collection failure indistinguishable from a health collection failure.

Nullable with no backfill: a drive whose health has never been reported is not a healthy drive, and defaulting to an empty list would read as no disks have problems on every endpoint predating the column.
This commit is contained in:
benjamin.baker
2026-09-05 06:25:49 -04:00
parent b297dea822
commit 024910d4f2
3 changed files with 98 additions and 1 deletions

View File

@@ -53,7 +53,7 @@ KNOWN_PAYLOAD_KEYS = frozenset({
'cpupercent', 'memorypercent', 'services', 'ports', 'interfaces',
'scheduledtasks', 'applications', 'events',
'disks', 'bios', 'memory', 'processor', 'osinfo', 'domain',
'pendingreboot', 'usercontext',
'pendingreboot', 'usercontext', 'diskhealth',
})
@@ -572,6 +572,30 @@ class EndpointsPlugin(BasePlugin):
'position': 17,
'group': 'monitors',
},
{
# Sits directly under Storage because an operator looking at a
# full disk and an operator looking at a dying disk are usually
# the same person in the same minute. Kept a separate panel
# because storage is per volume and this is per physical
# device; the two do not share a key.
'id': 'endpoints-diskhealth',
'title': 'Disk health',
'assettypes': ['computer'],
'endpoint': '/api/endpoints/asset/{assetid}/diskhealth',
'render': 'table',
'collapsible': True,
'columns': [
{'key': 'model', 'label': 'Drive'},
{'key': 'mediatype', 'label': 'Media'},
{'key': 'bustype', 'label': 'Bus'},
{'key': 'healthstatus', 'label': 'Health'},
{'key': 'verdict', 'label': 'Verdict'},
{'key': 'source', 'label': 'Source'},
],
'empty': 'No disk health reported.',
'position': 18,
'group': 'monitors',
},
{
# The listening set is the box's exposed network surface. The
# full connection table is deliberately not here: it is an
@@ -757,6 +781,15 @@ class EndpointsPlugin(BasePlugin):
'reboot. Absent means never reported, '
'which is not the same as false.',
},
'diskhealth': {
'type': 'array',
'description': 'Per-drive health from two sources: '
'physicaldisk (the only one reporting '
'NVMe) and diskdrive (ATA/SATA and '
'fallback). Each entry carries a '
'precomputed predictfailure; null there '
'means the drive did not answer.',
},
'usercontext': {
'type': 'object',
'description': 'Session-scoped data collected by a probe '
@@ -1000,6 +1033,8 @@ class EndpointsPlugin(BasePlugin):
state.domain = payload.get('domain')
if 'pendingreboot' in payload:
state.pendingreboot = payload.get('pendingreboot')
if 'diskhealth' in payload:
state.diskhealthjson = _as_json(payload.get('diskhealth'))
# Session-scoped probe output. The agent repeats its last result between
# probe runs and omits the key entirely when nobody is signed in, so the