diff --git a/plugins/endpoints/migrations/versions/0014_endpoint_diskhealth.py b/plugins/endpoints/migrations/versions/0014_endpoint_diskhealth.py new file mode 100644 index 00000000..1d7de901 --- /dev/null +++ b/plugins/endpoints/migrations/versions/0014_endpoint_diskhealth.py @@ -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) diff --git a/plugins/endpoints/models/endpoint.py b/plugins/endpoints/models/endpoint.py index 1113ea0f..025b16b8 100644 --- a/plugins/endpoints/models/endpoint.py +++ b/plugins/endpoints/models/endpoint.py @@ -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 diff --git a/plugins/endpoints/plugin.py b/plugins/endpoints/plugin.py index 8d55bc49..dac7bdf3 100644 --- a/plugins/endpoints/plugin.py +++ b/plugins/endpoints/plugin.py @@ -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