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

@@ -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 [