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.
268 lines
11 KiB
Python
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),
|
|
}
|