Files
shopdb-flask/plugins/endpoints/models/health.py
cproudlock 534f179ee6 Make main the lean product a site receives
main was 16 days and 259 commits behind the line the product is actually built
on, at contract 0.30.0 against 0.35.0, with no per-PC credential code at all. It
was neither current nor curated - just stale. This brings it forward and, at the
same time, makes it mean something: main is what a receiving site installs, and
nothing else.

WHAT IT CARRIES. Twelve plugins - backups, computers, endpoints, guides,
knowledgebase, machines, network, notifications, printers, slides, tools,
warranty - plus applications, which is manifest-less core and always ships. The
only manifest dependency inside that set is endpoints on computers, and both are
in it, so the closure is self-contained.

WHAT IT DOES NOT. employees, geenforce, measuringtools, printedparts and usb
stay on the site line. So do the Windows installer tree, the VM rehearsal
helpers, the agent tooling config, one site's data loader, the internal audit
and findings records, and the planner spreadsheet. 1,135 tracked files against
1,438.

A REMOVAL THAT BREAKS THE APP IS NOT A REMOVAL, and two nearly shipped:

deploy/site-profile-universal.json still named all seventeen plugins. It is the
file docs/DEPLOY.md tells a site to provision from, so a fresh install would
have failed at `flask plugin apply-profile` with PluginError - the first thing a
receiving site does, broken by the branch meant to serve them. It now names the
twelve that exist, and its comment says the two must agree.

agent/ was never refreshed. Whoever populated this branch copied shopdb/,
plugins/, frontend/ and tests/ from the site line and left the agent tree at its
September state: 22 C# and PowerShell sources missing, including AgentCredentials,
EnrollmentService, AgentHttpFactory and the fleet-alert client, plus one file the
site line had deleted still present. The old tree is internally consistent, so it
compiles, and nothing in CI builds it - every gate was green over a server that
speaks enrollment and per-PC credentials to a bundled agent source that cannot.
Resynced whole.

DECISIONS THIS ENCODES, rather than defers:

- The plugin-authoring lab builds printedparts, whose manifest depends on
  employees, so it could not stay. The lab remains on the site line and main
  ships no build-along tutorial. That is a real loss for an adopting developer
  and is recorded here rather than discovered later.
- Recognition, Training and Recertification notifications are no longer seeded.
  Each splits one notification into a card per employee and renders a photo,
  which needs a plugin this branch does not bundle. Seeding them would offer an
  author a type that cannot resolve anybody. A site that installs employees gets
  them from that plugin's own seed.
- Windows/IIS is documented as the legacy path throughout, and the README leads
  with Docker, because that is what a receiving site gets.

The API description is regenerated for THIS branch - 347 paths and 483
operations, down from 415 and 573 - so the published contract describes what is
here rather than what the site line serves. docs/CONFIG.md lost the two
measuring-tool label keys the code no longer declares, which two lanes had
decided opposite ways.

Suite: 3124 passed, 6 skipped. Naming and style gates pass. The docs gates run
rather than skip, and every index line count is derived from the file it names.
2026-09-19 11:24:40 -04:00

268 lines
11 KiB
Python

"""Endpoint health rollup: cached verdict, transition history, impact mapping.
Three tables, one job: decide what the Fleet Health column says about an
endpoint, decide it ONCE when the data changes, and keep enough history to
answer "when did this break".
Why this exists at all. The verdict used to be computed on every Fleet page
load by aggregating 48h of endpointevents fleet-wide, which cost more the
longer the fleet ran, and it ranked a raw Windows event count above every
other signal. A box with a crash loop reported "Application crash x5861" while
its dead eDNC service - the thing that actually stops work on the floor - was
structurally unreachable, because the event branch returned first.
The model here separates two things the old rollup conflated:
events Point-in-time facts. They happened, they are counted, they age
out of the window on their own.
conditions A stopped service or a disconnected drive is not an event, it is
a STATE with a duration. It opens, it persists, and it is only
over when something observes it to be over.
Ranking a condition against an event count is comparing incompatible things.
Conditions are pinned until explicitly resolved; events are ranked beneath
them. That is the whole reason the Health column can be trusted now.
"""
from datetime import datetime
from shopdb.api import db
def _utciso(value):
"""Naive-UTC datetime -> ISO string marked UTC, or None."""
return value.isoformat() + 'Z' if value else None
# Operational impact, in the site's terms rather than the OS's. Windows calls a
# crash severe; the floor calls a disconnected drive a stoppage. These disagree,
# and the floor wins - which is why this vocabulary is not the event catalog's
# 'critical'/'warning'/'info'.
IMPACT_BLOCKER = 'blocker'
IMPACT_DEGRADED = 'degraded'
IMPACT_INFORMATIONAL = 'informational'
IMPACT_CLASSES = (IMPACT_BLOCKER, IMPACT_DEGRADED, IMPACT_INFORMATIONAL)
# Lower sorts worse, matching _DRIVE_STATUS_RANK's existing convention so the
# two ladders in this plugin do not read in opposite directions.
IMPACT_RANK = {
IMPACT_BLOCKER: 0,
IMPACT_DEGRADED: 1,
IMPACT_INFORMATIONAL: 2,
}
# Where a verdict came from. Kept alongside sourceref so the UI can deep-link
# back to the monitor or the event that produced the label.
SOURCE_DRIVE = 'drive'
SOURCE_SERVICE = 'service'
SOURCE_PROCESS = 'process'
SOURCE_EVENT = 'event'
SOURCE_NONE = 'none'
class EndpointHealthState(db.Model):
"""The current health verdict for one endpoint - the Fleet page's only read.
Cached deliberately. The Fleet list joins this and reads it directly; it
does not recompute anything per row. Written in the SAME commit as the
check-in that produced it, so the cached verdict and the telemetry it
reflects can never disagree (the ledger pattern this codebase already uses
for quantityonhand).
"""
__tablename__ = 'endpointhealthstates'
endpointhealthstateid = db.Column(db.Integer, primary_key=True)
# One row per asset. Unique rather than a plain index: two live verdicts
# for one endpoint is not a state this table should be able to represent.
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
unique=True,
)
# Display vocabulary, matching what the Fleet renderer already understands:
# 'ok', 'warning', 'critical', 'unknown'.
level = db.Column(db.String(16), nullable=False, default='unknown')
# The phrase an operator reads ("eDNC stopped", "S: disconnected"). A
# PHRASE, not a score - it has to say what to do.
label = db.Column(db.String(255), nullable=True)
# Null for an ok/unknown verdict: nothing is wrong, so nothing has impact.
impactclass = db.Column(db.String(16), nullable=True)
sourcekind = db.Column(db.String(16), nullable=False, default=SOURCE_NONE)
# Monitor id, drive letter or Windows event id, depending on sourcekind.
# Free text because those identifier spaces have nothing in common.
sourceref = db.Column(db.String(64), nullable=True)
# When the CURRENT condition opened. Survives re-evaluation: a service that
# has been down for three days keeps its original firstseenat, so the UI can
# say how long rather than only that it is down now.
firstseenat = db.Column(db.DateTime, nullable=True)
# Last time an agent explicitly observed this endpoint. Drives staleness:
# a pinned blocker on an endpoint that stopped reporting is neither
# resolvable nor trustworthy, and must not render as present tense.
lastconfirmedat = db.Column(db.DateTime, nullable=True)
lastevaluatedat = db.Column(db.DateTime, nullable=False,
default=datetime.utcnow)
# Transitions inside the trailing window. A service flapping ten times an
# hour is a different fault from one cleanly down, and dispatching someone
# to a service that has already restarted twice wastes a trip.
flapcount = db.Column(db.Integer, nullable=False, default=0)
__table_args__ = (
db.Index('idx_endpointhealthstates_asset', 'assetid'),
db.Index('idx_endpointhealthstates_impactclass', 'impactclass'),
)
def __repr__(self):
return f"<EndpointHealthState asset={self.assetid} {self.level}>"
def to_dict(self):
return {
'assetid': self.assetid,
'level': self.level,
'label': self.label,
'impactclass': self.impactclass,
'sourcekind': self.sourcekind,
'sourceref': self.sourceref,
'firstseenat': _utciso(self.firstseenat),
'lastconfirmedat': _utciso(self.lastconfirmedat),
'lastevaluatedat': _utciso(self.lastevaluatedat),
'flapcount': self.flapcount,
}
class EndpointHealthTransition(db.Model):
"""One verdict CHANGE for an endpoint. Written on change, never on sample.
Storing a row per check-in would make the last 50 rows cover a few minutes
and say nothing. Storing a row per change makes 50 rows cover months on a
stable endpoint, compresses "nothing happened" to zero cost, and answers
the question operators actually ask - when did this break - directly rather
than by inferring it from a gap between samples.
It also makes flapping measurable: 50 transitions in an hour and 50 over a
year are the same row count and obviously different faults.
"""
__tablename__ = 'endpointhealthtransitions'
endpointhealthtransitionid = db.Column(db.Integer, primary_key=True)
assetid = db.Column(
db.Integer,
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
nullable=False,
)
level = db.Column(db.String(16), nullable=False)
label = db.Column(db.String(255), nullable=True)
impactclass = db.Column(db.String(16), nullable=True)
sourcekind = db.Column(db.String(16), nullable=False, default=SOURCE_NONE)
sourceref = db.Column(db.String(64), nullable=True)
openedat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# Null means STILL OPEN. Retention never evicts an unresolved row: at 50
# deep a long-running blocker's opening transition would otherwise age out
# behind a burst of unrelated churn, deleting the record of why the thing
# currently on screen is on screen.
resolvedat = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.Index('idx_endpointhealthtransitions_assetopened',
'assetid', 'openedat'),
db.Index('idx_endpointhealthtransitions_assetresolved',
'assetid', 'resolvedat'),
)
def __repr__(self):
return (f"<EndpointHealthTransition asset={self.assetid} "
f"{self.level} opened={self.openedat}>")
def to_dict(self):
return {
'endpointhealthtransitionid': self.endpointhealthtransitionid,
'assetid': self.assetid,
'level': self.level,
'label': self.label,
'impactclass': self.impactclass,
'sourcekind': self.sourcekind,
'sourceref': self.sourceref,
'openedat': _utciso(self.openedat),
'resolvedat': _utciso(self.resolvedat),
}
class EndpointMonitorImpact(db.Model):
"""How much a given monitor matters ON A GIVEN PC TYPE.
eDNC stopped on a shopfloor PC halts work; the same service stopped on a
lobby display is noise. Impact is therefore a property of the monitor IN
CONTEXT, not of the monitor and not of the event type - which is why it
cannot live on endpointmonitors and could never have lived in the event
catalog.
Why a plugin-owned side table rather than a column on the policy action:
monitors bind to PC types through configurationpolicyactions, which is a
CORE table shared by every plugin. Adding an endpoints-specific impact
column there would be a contract-surface change imposed on unrelated
plugins. This keeps the concept inside the plugin that owns it.
ABSENCE OF A ROW IS THE DEFAULT, and that is load-bearing. An endpoint with
no policies, no PC type, or no monitors at all resolves to the built-in
default impact for its source kind - the same verdict the old rollup gave.
The no-policy path is the NORMAL path here, not a special case.
"""
__tablename__ = 'endpointmonitorimpacts'
endpointmonitorimpactid = db.Column(db.Integer, primary_key=True)
endpointmonitorid = db.Column(
db.Integer,
db.ForeignKey('endpointmonitors.endpointmonitorid',
ondelete='CASCADE'),
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 below does
# not already provide.
computertypeid = db.Column(db.Integer, nullable=False)
impactclass = db.Column(db.String(16), nullable=False,
default=IMPACT_DEGRADED)
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
__table_args__ = (
db.UniqueConstraint('endpointmonitorid', 'computertypeid',
name='uq_endpointmonitorimpacts_monitortype'),
db.Index('idx_endpointmonitorimpacts_computertypeid', 'computertypeid'),
)
def __repr__(self):
return (f"<EndpointMonitorImpact monitor={self.endpointmonitorid} "
f"pctype={self.computertypeid} {self.impactclass}>")
def to_dict(self):
return {
'endpointmonitorimpactid': self.endpointmonitorimpactid,
'endpointmonitorid': self.endpointmonitorid,
'computertypeid': self.computertypeid,
'impactclass': self.impactclass,
'createdat': _utciso(self.createdat),
}