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:
@@ -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)
|
||||
Reference in New Issue
Block a user