Dashboard cards, backup last-seen
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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')
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
85
plugins/backups/services/staleness.py
Normal file
85
plugins/backups/services/staleness.py
Normal 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]
|
||||
@@ -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])
|
||||
|
||||
@@ -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}',
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in New Issue
Block a user