Cache endpoint health as a ranked rollup

Fleet ranked endpoints by raw critical-event counts, so a machine with a
noisy but harmless event log outranked one with a mapped drive down or a
blocking service stopped. Volume is not severity, and the aggregation ran
across the whole fleet on every page load.

Rank operational blockers above degraded conditions above event noise, and
cache the verdict per asset so Fleet reads a row instead of recomputing 48h
of history. Transitions are written only when a verdict changes, capped at
50 rows per asset.

Absence of a policy binding must never suppress a verdict: drives and events
are not policy-gated, and services fall back to a built-in default impact
when no mapping applies. Event 1001 drops to informational because the WER
bucket pairs with 1000 and double-counted every crash.

Parallelize the Fleet first-paint loaders that were awaiting serially.
This commit is contained in:
benjamin.baker
2026-09-07 05:17:19 -04:00
parent 811e6ca61a
commit 04616fce8b
9 changed files with 1219 additions and 72 deletions

View File

@@ -0,0 +1,133 @@
"""Add endpoint health rollup tables.
Three tables behind the Fleet Health column:
endpointhealthstates one cached verdict per endpoint, read directly by
the Fleet list instead of recomputing a 48h
fleet-wide event aggregate on every page load
endpointhealthtransitions capped history, written on CHANGE not on sample
endpointmonitorimpacts per-PC-type operational impact for a monitor
No backfill. An endpoint with no row has simply not checked in since this
shipped, and the first check-in writes one. Backfilling would have to invent a
firstseenat for every existing condition, and a fabricated "broken since" is
worse than an absent one - an operator reading it has no way to tell it apart
from an observed timestamp.
endpointmonitorimpacts ships EMPTY on purpose. Absence of a row means the
built-in default impact applies, so an endpoint with no policies behaves
exactly as it did before this migration. Seeding defaults here would freeze
today's guesses into rows nobody could distinguish from deliberate choices.
Idempotent: every create 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 = 'endpoints0017healthrollup'
down_revision = 'endpoints0016dellupdates'
branch_labels = None
depends_on = None
HEALTHSTATES = 'endpointhealthstates'
TRANSITIONS = 'endpointhealthtransitions'
IMPACTS = 'endpointmonitorimpacts'
def _has_table(table_name):
from sqlalchemy import inspect
return inspect(op.get_bind()).has_table(table_name)
def upgrade():
if not _has_table(HEALTHSTATES):
op.create_table(
HEALTHSTATES,
sa.Column('endpointhealthstateid', sa.Integer(), nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('level', sa.String(length=16), nullable=False,
server_default='unknown'),
sa.Column('label', sa.String(length=255), nullable=True),
sa.Column('impactclass', sa.String(length=16), nullable=True),
sa.Column('sourcekind', sa.String(length=16), nullable=False,
server_default='none'),
sa.Column('sourceref', sa.String(length=64), nullable=True),
sa.Column('firstseenat', sa.DateTime(), nullable=True),
sa.Column('lastconfirmedat', sa.DateTime(), nullable=True),
sa.Column('lastevaluatedat', sa.DateTime(), nullable=False),
sa.Column('flapcount', sa.Integer(), nullable=False,
server_default='0'),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointhealthstateid'),
# One live verdict per endpoint. Unique rather than indexed: two
# rows for one asset is not a state this table should represent.
sa.UniqueConstraint('assetid',
name='uq_endpointhealthstates_assetid'),
)
op.create_index('idx_endpointhealthstates_asset', HEALTHSTATES,
['assetid'])
op.create_index('idx_endpointhealthstates_impactclass', HEALTHSTATES,
['impactclass'])
if not _has_table(TRANSITIONS):
op.create_table(
TRANSITIONS,
sa.Column('endpointhealthtransitionid', sa.Integer(),
nullable=False),
sa.Column('assetid', sa.Integer(), nullable=False),
sa.Column('level', sa.String(length=16), nullable=False),
sa.Column('label', sa.String(length=255), nullable=True),
sa.Column('impactclass', sa.String(length=16), nullable=True),
sa.Column('sourcekind', sa.String(length=16), nullable=False,
server_default='none'),
sa.Column('sourceref', sa.String(length=64), nullable=True),
sa.Column('openedat', sa.DateTime(), nullable=False),
sa.Column('resolvedat', sa.DateTime(), nullable=True),
sa.ForeignKeyConstraint(['assetid'], ['assets.assetid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointhealthtransitionid'),
)
# Retention reads newest-first per asset; the resolved index serves the
# "never evict an unresolved row" filter without scanning history.
op.create_index('idx_endpointhealthtransitions_assetopened',
TRANSITIONS, ['assetid', 'openedat'])
op.create_index('idx_endpointhealthtransitions_assetresolved',
TRANSITIONS, ['assetid', 'resolvedat'])
if not _has_table(IMPACTS):
op.create_table(
IMPACTS,
sa.Column('endpointmonitorimpactid', sa.Integer(), nullable=False),
sa.Column('endpointmonitorid', sa.Integer(), nullable=False),
# Plain integer, not a ForeignKey: computertypes belongs to the
# computers plugin and owns its own migration chain. A cross-plugin
# FK would couple the two chains' ordering for no benefit the
# unique constraint does not already provide.
sa.Column('computertypeid', sa.Integer(), nullable=False),
sa.Column('impactclass', sa.String(length=16), nullable=False,
server_default='degraded'),
sa.Column('createdat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['endpointmonitorid'],
['endpointmonitors.endpointmonitorid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('endpointmonitorimpactid'),
sa.UniqueConstraint('endpointmonitorid', 'computertypeid',
name='uq_endpointmonitorimpacts_monitortype'),
)
op.create_index('idx_endpointmonitorimpacts_computertypeid', IMPACTS,
['computertypeid'])
def downgrade():
# Dropped newest-first so the FK to endpointmonitors goes before anything
# that might reference it. All three are caches or mappings - nothing here
# is a system of record, so a downgrade loses no telemetry.
for table_name in (IMPACTS, TRANSITIONS, HEALTHSTATES):
if _has_table(table_name):
op.drop_table(table_name)