Dashboard cards, backup last-seen

This commit is contained in:
2026-08-11 14:01:57 -04:00
parent c30195a92b
commit 0ed8be75d9
18 changed files with 1167 additions and 9 deletions

View File

@@ -344,3 +344,19 @@ def _diffprojections(old, new):
'aftertype': None if after is None else after[0],
})
return changes
@backups_bp.route('/dashboard/stale', methods=['GET'])
@jwt_required()
@require_permission('backups.view')
def dashboard_stale():
"""Chains whose backup has stopped running.
Thin: the rule lives in services/staleness.py, where it is testable without
an auth layer in the way. Keyed on the last CONFIRMED check, never on the
last change - dedup means an unchanged config writes no revision, so a card
keyed on revision age would flag most of a healthy fleet.
"""
from ..services.staleness import stalechains
return success_response(stalechains())

View File

@@ -0,0 +1,43 @@
"""backups: record when a configuration was last confirmed unchanged.
Dedup means an unchanged config writes NO revision, so `collectedat` moves only
when something changes. A machine whose settings have been stable for six months
therefore has a six-month-old newest revision and is entirely healthy - and
ShopDB had no way to tell it apart from a machine whose backup stopped running
six months ago. The evidence that a check happened existed only in a log file on
the PC.
`lastseenat` records the check rather than the change. It is touched on every
matching post, including the no-op that writes nothing else.
Backfilled from `collectedat` (falling back to `createdat`) so existing rows
start from the last moment we can actually prove the config was current, rather
than from now - claiming a fresh check that never happened would be worse than
saying nothing.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'backups0002lastseenat'
down_revision = 'backups0001baseline'
branch_labels = None
depends_on = None
def upgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' not in columns:
op.add_column('backuprevisions',
sa.Column('lastseenat', sa.DateTime(), nullable=True))
op.execute('UPDATE backuprevisions '
'SET lastseenat = COALESCE(collectedat, createdat)')
def downgrade():
columns = {c['name'] for c in
sa.inspect(op.get_bind()).get_columns('backuprevisions')}
if 'lastseenat' in columns:
op.drop_column('backuprevisions', 'lastseenat')

View File

@@ -89,6 +89,16 @@ class BackupRevision(db.Model):
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
# When this configuration was last CONFIRMED still current, which is not the
# same as when it last changed. Dedup means an unchanged config writes no
# revision, so collectedat only ever moves on a change - a machine whose
# settings have been stable for six months has a six-month-old newest
# revision and is perfectly healthy. Without this column ShopDB cannot tell
# that machine from one whose backup stopped running six months ago, which
# is the one question a backup system has to be able to answer. Touched on
# every matching post, including the no-op that writes nothing else.
lastseenat = db.Column(db.DateTime, nullable=True)
__table_args__ = (
db.Index('ixbackuprevisionsassetkind', 'assetid', 'backupkind'),
)
@@ -138,6 +148,10 @@ class BackupRevision(db.Model):
# BROWSER-LOCAL and the timestamp silently shifts by the viewer's
# offset before any site-timezone formatting is applied.
'collectedat': _utciso(self.collectedat),
# When the config was last CONFIRMED current, versus when it last
# changed. The history view needs both or a stable machine looks
# abandoned.
'lastseenat': _utciso(self.lastseenat),
'createdat': _utciso(self.createdat),
}
if includepayload:

View File

@@ -161,6 +161,17 @@ class BackupsPlugin(BasePlugin):
'description': 'UNC root that opaque (non-JSON) backups are '
'written under by the collecting PC. Site-specific.',
},
{
'key': 'backups_staledays',
'value': '3',
'valuetype': 'integer',
'category': 'backups',
'description': 'Days without a CONFIRMED check before a backup '
'is listed as stopped on the dashboard. Measured '
'from the last check, not the last change - an '
'unchanged config writes no revision. 0 disables '
'the card.',
},
{
'key': 'backups_retentioncount',
'value': '50',
@@ -180,6 +191,36 @@ class BackupsPlugin(BasePlugin):
},
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard card: backups that have stopped running.
Keyed on the last CONFIRMED check, never on the last change. Dedup means
an unchanged config writes no revision, so a card keyed on revision age
would flag most of a healthy fleet.
"""
return [
{
'id': 'backups-stale',
'title': 'Backups stopped',
'endpoint': '/api/backups/dashboard/stale',
'render': 'exceptions',
'severity': 'warning',
'permission': 'backups.view',
'empty': 'hide',
'position': 30,
'map': {
'title': 'assetnumber',
'detail': 'backupkind',
'meta': [
{'key': 'sourcehostname', 'label': 'from'},
{'key': 'quietdays', 'label': 'last checked',
'suffix': ' days ago'},
],
'link': '/backups/asset/{assetid}',
},
},
]
# ---- ADR-006 collector contract -------------------------------------
def get_collector_schema(self) -> Optional[dict]:
@@ -299,6 +340,13 @@ class BackupsPlugin(BasePlugin):
.first())
if latest is not None and latest.contenthash == contenthash:
# Unchanged, so no revision - but RECORD THE CHECK. Without this the
# only evidence a backup still runs is a line in a log on the PC,
# and a config stable for six months is indistinguishable from a
# backup that died six months ago. Cheap: one column on a row that
# already exists, no new history.
latest.lastseenat = datetime.utcnow()
db.session.flush()
return {
'action': 'noop',
'assetid': assetid,
@@ -330,6 +378,7 @@ class BackupsPlugin(BasePlugin):
bytesize=bytesize,
sourcehostname=payload.get('sourcehostname'),
collectedat=collectedat or datetime.utcnow(),
lastseenat=datetime.utcnow(),
)
revision.payload = projection
db.session.add(revision)

View File

@@ -0,0 +1,85 @@
"""Which backup chains have stopped running.
Split from the route for the same reason retention.py is: the rule here is the
interesting part and it should be testable without an auth layer in the way.
THE RULE, and it is not the obvious one. Dedup means an unchanged configuration
writes no revision, so `collectedat` moves only when something CHANGES. A
machine whose settings have been stable for six months has a six-month-old
newest revision and is entirely healthy. Staleness is therefore measured from
`lastseenat` - when the config was last CONFIRMED current, recorded on every
matching post including the no-op that writes nothing else.
Keyed on the CHAIN (asset, kind, source PC), because that is the unit that fails
independently. A machine with two part markers can have one still reporting
while the other stopped, and a per-asset view would report the machine as fine.
"""
from datetime import datetime, timedelta
from shopdb.api import db, Asset
from ..models import BackupRevision
DEFAULTSTALEDAYS = 3
def staledays():
"""Configured threshold; 0 or negative disables the card entirely."""
from shopdb.api import Setting
setting = Setting.query.filter_by(key='backups_staledays').first()
if setting and (setting.value or '').strip():
try:
return int(setting.value)
except (TypeError, ValueError):
pass
return DEFAULTSTALEDAYS
def latestperchain():
"""Newest revision of every (asset, kind, source) chain."""
latest = {}
for revision in (BackupRevision.query
.order_by(BackupRevision.backuprevisionid.asc()).all()):
latest[(revision.assetid, revision.backupkind,
revision.sourcehostname)] = revision
return latest
def stalechains(days=None, limit=50):
"""Chains whose last confirmed check is older than the threshold.
Deliberately silent about assets that have NEVER been backed up. Whether one
SHOULD be is a question only the manifest can answer - most machines carry
no NTLARS at all - and guessing would list a hundred healthy assets, which
is the noise that makes a board worth ignoring. That is the
desired-versus-observed card, which needs the manifest resolver.
"""
days = staledays() if days is None else days
if days <= 0:
return []
now = datetime.utcnow()
cutoff = now - timedelta(days=days)
rows = []
for revision in latestperchain().values():
# Rows written before lastseenat existed fall back to the timestamps
# that do exist, so an old install reports something sane on day one
# rather than every chain at once.
seen = revision.lastseenat or revision.collectedat or revision.createdat
if seen is None or seen >= cutoff:
continue
asset = db.session.get(Asset, revision.assetid)
rows.append({
'assetid': revision.assetid,
'assetnumber': asset.assetnumber if asset else str(revision.assetid),
'backupkind': revision.backupkind,
'sourcehostname': revision.sourcehostname,
'lastseenat': seen.isoformat() + 'Z',
'quietdays': (now - seen).days,
})
rows.sort(key=lambda r: -r['quietdays'])
return rows[:limit]

View File

@@ -934,3 +934,63 @@ def dashboard_summary():
'shopfloor': shopfloor_count,
'nonshopfloor': total - shopfloor_count
})
@computers_bp.route('/dashboard/quiet', methods=['GET'])
@jwt_required()
@require_permission('computers.view')
def dashboard_quiet():
"""PCs that have stopped reporting, and PCs that never started.
Two populations, deliberately distinguished rather than merged into one
count. A PC that reported and went quiet is probably switched off, moved or
broken. A PC that has NEVER reported is worse: it is either not enrolled, or
enrolled against the wrong pc-type, so nothing enforces anything on it and
no backup of it exists. That one hides indefinitely because nothing about it
is failing loudly - and it is exactly the shape of the bay that carried a
wrong machine number for weeks.
Silence is the only signal available here. There is no heartbeat separate
from the report, so 'stopped reporting' is measured against the collector's
own last write.
"""
from datetime import datetime, timedelta, timezone
hours = 24
setting = Setting.query.filter_by(key='computers_quietreporthours').first()
if setting and (setting.value or '').strip():
try:
hours = int(setting.value)
except (TypeError, ValueError):
pass
cutoff = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(hours=hours)
query = (db.session.query(Computer, Asset)
.join(Asset, Asset.assetid == Computer.assetid)
.filter(Asset.isactive.is_(True)))
rows = []
for comp, asset in query.all():
last = comp.lastreporteddate
if last is None:
rows.append({
'computerid': comp.computerid,
'hostname': comp.hostname,
'state': 'never reported',
'lastreported': None,
'quietdays': None,
})
elif last < cutoff:
rows.append({
'computerid': comp.computerid,
'hostname': comp.hostname,
'state': 'stopped reporting',
'lastreported': last.isoformat() + 'Z',
'quietdays': (datetime.now(timezone.utc).replace(tzinfo=None)
- last).days,
})
# Never-reported first, then longest silence: the order someone should work
# down the list, not the order the rows came out of the table.
rows.sort(key=lambda r: (r['quietdays'] is not None, -(r['quietdays'] or 0)))
return success_response(rows[:50])

View File

@@ -163,6 +163,14 @@ class ComputersPlugin(BasePlugin):
site that installed an earlier one.
"""
return [
{
'key': 'computers_quietreporthours',
'value': '24',
'valuetype': 'integer',
'category': 'computers',
'description': 'Hours without a collector report before a PC '
'is listed as not reporting on the dashboard.',
},
{
'key': 'computers_machinelink_alerts',
'value': 'false',
@@ -1247,14 +1255,30 @@ class ComputersPlugin(BasePlugin):
return [computerscli]
def get_dashboard_widgets(self) -> List[Dict]:
"""Return dashboard widget definitions."""
"""Dashboard cards this plugin contributes.
REPLACES a declaration that named a Vue component which was never
written - as did four other plugins - so nothing rendered and nobody
noticed, because the frontend never called the widgets endpoint either.
The contract now carries data and a renderer; core draws it.
"""
return [
{
'name': 'Computer Status',
'component': 'ComputerStatusWidget',
'endpoint': '/api/computers/dashboard/summary',
'size': 'medium',
'position': 6,
'id': 'computers-quiet',
'title': 'PCs not reporting',
'endpoint': '/api/computers/dashboard/quiet',
'render': 'exceptions',
'severity': 'warning',
'permission': 'computers.view',
'empty': 'hide',
'position': 20,
'map': {
'title': 'hostname',
'detail': 'state',
'meta': [{'key': 'quietdays', 'label': 'quiet for',
'suffix': ' days'}],
'link': '/pcs/{computerid}',
},
},
]

View File

@@ -26,7 +26,8 @@ SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ManifestPayload, ManifestBlob, ENTRY_TYPES, PHASES,
ManifestEnforcementReport, ManifestEnforcementResult, ManifestPayload,
ManifestBlob, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
@@ -954,3 +955,57 @@ def get_report(reportid):
'message': r.message,
} for r in report.results],
})
@geenforce_bp.route('/dashboard/failures', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def dashboard_failures():
"""Entries that FAILED on their PC's most recent enforcement cycle.
The dashboard card behind this answers a question nothing else does: what is
broken on the floor right now. The reports table has held it all along -
status, the failing entry, its exit code and the engine's message - and the
only way to see any of it was to open one PC's report modal, one PC at a
time. A bay returned 500 to every collector report for a day and a half
before anyone looked.
Per ENTRY, not per report: "three PCs failed" is a number, while "Install
OpenText failed with exit 1603 on WJSF1234" is something a person can act
on. Only current reports (iscurrent) are considered, so a failure that has
since been fixed disappears on its own rather than needing dismissing.
"""
rows = (db.session.query(ManifestEnforcementResult, ManifestEnforcementReport)
.join(ManifestEnforcementReport,
ManifestEnforcementResult.reportid ==
ManifestEnforcementReport.reportid)
.filter(ManifestEnforcementReport.iscurrent.is_(True),
ManifestEnforcementResult.action == 'failed')
.order_by(ManifestEnforcementReport.receivedat.desc())
.limit(50)
.all())
# Resolve hostnames to computerids in ONE query so the card can link to the
# PC rather than just naming it. A row whose PC is unknown to shopdb still
# shows - the failure is real even when the inventory is behind.
hostnames = {report.hostname for _result, report in rows}
idbyhost = {}
if hostnames:
try:
from plugins.computers.models import Computer
for computer in Computer.query.filter(
Computer.hostname.in_(hostnames)).all():
idbyhost[(computer.hostname or '').lower()] = computer.computerid
except ImportError:
pass
return success_response([{
'hostname': report.hostname,
'computerid': idbyhost.get((report.hostname or '').lower()),
'scopename': report.scopename,
'entryname': result.entryname,
'exitcode': result.exitcode,
'message': (result.message or '').strip()[:200],
'receivedat': (report.receivedat.isoformat() + 'Z'
if report.receivedat else None),
} for result, report in rows])

View File

@@ -66,6 +66,42 @@ class GeEnforcePlugin(BasePlugin):
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard cards this plugin contributes.
DATA AND SHAPE, not a component name. The older widget contract named a
Vue component per widget, which cannot survive a lean build - a
plugin's component may never be staged into the frontend bundle - and
is why five plugins declared widgets pointing at components nobody ever
wrote. Core owns a small set of generic renderers; a plugin says what
to show and how to link it. Same lesson ADR-010 already applied to
asset panels.
`empty: hide` matters as much as the data. A card that reports "nothing
wrong" every day teaches people to stop reading the page, which is
exactly how a fleet log reached 3,234 lines with 17 that mattered.
"""
return [
{
'id': 'geenforce-failures',
'title': 'Enforcement failures',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
'severity': 'critical',
'permission': 'geenforce.manage',
'empty': 'hide',
'position': 10,
'map': {
'title': 'hostname',
'detail': 'entryname',
'meta': [{'key': 'message'}, {'key': 'exitcode',
'label': 'exit'}],
'link': '/pcs/{computerid}',
'timestamp': 'receivedat',
},
},
]
def get_permissions(self) -> List:
"""RBAC permissions this plugin owns (edit vs ship are split)."""
return [