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:
@@ -0,0 +1,56 @@
|
||||
"""Add diskhealthjson to endpointstates.
|
||||
|
||||
Per-drive health, kept separate from disksjson. That column answers "how full
|
||||
is this drive"; this one answers "is this drive dying". Merging them would mean
|
||||
a capacity collection failure and a health collection failure could not be told
|
||||
apart, and the two come from different WMI sources with different failure modes.
|
||||
|
||||
JSON rather than scalar columns because a machine has a variable number of
|
||||
drives and each carries its own verdict, and because the agent reports the same
|
||||
drive from two sources - MSFT_PhysicalDisk for NVMe and Win32_DiskDrive for
|
||||
ATA/SATA - which the server keeps side by side rather than collapsing.
|
||||
|
||||
MEDIUMTEXT for consistency with the other snapshot columns on this table.
|
||||
|
||||
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 that predates this column.
|
||||
|
||||
Idempotent: the column-add checks the live schema first, so a re-run and a
|
||||
db.create_all() site both stay clean on strict MySQL 8.
|
||||
"""
|
||||
from alembic import op
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'endpoints0014diskhealth'
|
||||
down_revision = 'endpoints0013inventorycolumns'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TABLE_NAME = 'endpointstates'
|
||||
COLUMN_NAME = 'diskhealthjson'
|
||||
|
||||
|
||||
def _has_column(table_name, column_name):
|
||||
from sqlalchemy import inspect
|
||||
|
||||
inspector = inspect(op.get_bind())
|
||||
if not inspector.has_table(table_name):
|
||||
return False
|
||||
return any(col['name'] == column_name
|
||||
for col in inspector.get_columns(table_name))
|
||||
|
||||
|
||||
def upgrade():
|
||||
if not _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.add_column(
|
||||
TABLE_NAME,
|
||||
sa.Column(COLUMN_NAME, sa.Text(length=16777215), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
if _has_column(TABLE_NAME, COLUMN_NAME):
|
||||
op.drop_column(TABLE_NAME, COLUMN_NAME)
|
||||
@@ -102,6 +102,12 @@ class EndpointState(db.Model):
|
||||
# is deliberately distinct from a confident False.
|
||||
pendingreboot = db.Column(db.Boolean, nullable=True)
|
||||
|
||||
# Per-drive health from the agent's two disk sources. JSON because a machine
|
||||
# has a variable number of drives and each carries its own verdict. Kept
|
||||
# separate from disksjson: that is capacity, this is whether the hardware is
|
||||
# dying. Null = never reported.
|
||||
diskhealthjson = db.Column(db.Text(length=16777215), nullable=True)
|
||||
|
||||
# Warn-only reconciler output, refreshed on every agent check-in in the same
|
||||
# commit as servicesjson. Each entry names a policy/shipped-attached monitor
|
||||
# the endpoint is expected to be running but the latest snapshot does not
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user