diff --git a/docs/proposals/dashboard-live-fleet.md b/docs/proposals/dashboard-live-fleet.md index fd3f2a9..3350169 100644 --- a/docs/proposals/dashboard-live-fleet.md +++ b/docs/proposals/dashboard-live-fleet.md @@ -1,6 +1,6 @@ # Proposal: a dashboard that shows the fleet, not the row count -- Status: DRAFT +- Status: ACCEPTED - Date: 2026-08-11 - Author: cproudlock - Relates to: ADR-010 (frontend plugin hooks), ADR-013 / ADR-014 (lean per-site builds), ADR-006 (collector contract), ADR-012 (GE-Enforce manifest ownership) diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index 3a245bc..64b4011 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -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]) diff --git a/plugins/geenforce/plugin.py b/plugins/geenforce/plugin.py index 030672c..79ac84c 100644 --- a/plugins/geenforce/plugin.py +++ b/plugins/geenforce/plugin.py @@ -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 [ diff --git a/tests/test_plugins/test_geenforce_dashboard.py b/tests/test_plugins/test_geenforce_dashboard.py new file mode 100644 index 0000000..c3f123f --- /dev/null +++ b/tests/test_plugins/test_geenforce_dashboard.py @@ -0,0 +1,123 @@ +"""GE-Enforce dashboard card: what is broken on the floor right now. + +The reports table has always held enforcement failures - the failing entry, its +exit code, 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. This card is the fix, so +these tests pin the behaviour that makes it useful rather than decorative. +""" + +from datetime import datetime + +from plugins.geenforce.models import (ManifestEnforcementReport, + ManifestEnforcementResult) + +URL = '/api/geenforce/dashboard/failures' + + +def _report(db, hostname, iscurrent=True, results=(), scopename='gea-shopfloor-cmm', + receivedat=None): + report = ManifestEnforcementReport( + hostname=hostname, scopename=scopename, phase='runtime', + status='failed', iscurrent=iscurrent, + receivedat=receivedat or datetime(2026, 8, 11, 12, 0, 0)) + db.session.add(report) + db.session.flush() + for entryname, action, exitcode, message in results: + db.session.add(ManifestEnforcementResult( + reportid=report.reportid, entryname=entryname, action=action, + exitcode=exitcode, message=message)) + db.session.commit() + return report + + +def test_a_failed_entry_is_listed_with_what_a_person_needs(client, db, auth_headers): + """Per ENTRY, not per report. "Three PCs failed" is a number; "Install + OpenText failed with exit 1603 on WJSF1234" is something to act on.""" + _report(db, 'WJSF1234', results=[ + ('Install OpenText', 'failed', 1603, 'Fatal error during installation')]) + + rows = client.get(URL, headers=auth_headers).get_json()['data'] + assert len(rows) == 1 + assert rows[0]['hostname'] == 'WJSF1234' + assert rows[0]['entryname'] == 'Install OpenText' + assert rows[0]['exitcode'] == 1603 + assert 'Fatal error' in rows[0]['message'] + + +def test_only_failures_appear(client, db, auth_headers): + """A cycle installs, skips and filters far more than it fails. Listing + anything but failures is how a card becomes wallpaper.""" + _report(db, 'WJSF1234', results=[ + ('Install OpenText', 'failed', 1603, 'boom'), + ('Install Acrobat', 'installed', 0, ''), + ('Set FMS host', 'skipped', 0, ''), + ('CMM settings', 'filtered', 0, 'PCTypes filter')]) + + rows = client.get(URL, headers=auth_headers).get_json()['data'] + assert [r['entryname'] for r in rows] == ['Install OpenText'] + + +def test_a_fixed_failure_disappears_on_its_own(client, db, auth_headers): + """Superseded reports are not current. A failure that has since been fixed + must clear itself rather than needing someone to dismiss it - otherwise the + card accumulates history and stops meaning "right now".""" + _report(db, 'WJSF1234', iscurrent=False, results=[ + ('Install OpenText', 'failed', 1603, 'boom')]) + _report(db, 'WJSF1234', iscurrent=True, results=[ + ('Install OpenText', 'installed', 0, '')]) + + assert client.get(URL, headers=auth_headers).get_json()['data'] == [] + + +def test_the_row_links_to_the_pc_when_shopdb_knows_it(client, db, auth_headers): + """A card that names a PC without linking to it is a worse report.""" + from plugins.computers.models import Computer + from shopdb.core.models import Asset, AssetType + + atype = AssetType.query.filter_by(assettype='computer').first() + if not atype: + atype = AssetType(assettype='computer') + db.session.add(atype) + db.session.flush() + asset = Asset(assetnumber='WJSF1234', + assettypeid=atype.assettypeid, statusid=1) + db.session.add(asset) + db.session.flush() + comp = Computer(assetid=asset.assetid, hostname='WJSF1234') + db.session.add(comp) + db.session.commit() + + _report(db, 'WJSF1234', results=[('Install OpenText', 'failed', 1603, 'x')]) + rows = client.get(URL, headers=auth_headers).get_json()['data'] + assert rows[0]['computerid'] == comp.computerid + + +def test_a_pc_shopdb_does_not_know_still_reports_its_failure(client, db, + auth_headers): + """The failure is real even when the inventory is behind. Dropping the row + would hide exactly the bay most likely to be misconfigured.""" + _report(db, 'GHOSTPC', results=[('Install OpenText', 'failed', 1603, 'x')]) + rows = client.get(URL, headers=auth_headers).get_json()['data'] + assert len(rows) == 1 + assert rows[0]['computerid'] is None + + +def test_the_card_is_permission_gated(client, db, member_headers): + """A dashboard card must not become a way around RBAC.""" + assert client.get(URL, headers=member_headers).status_code == 403 + + +def test_the_widget_declares_data_and_a_renderer_not_a_component(app): + """The old contract named a Vue component per widget, which cannot survive + a lean build - which is why five plugins declared widgets pointing at + components nobody wrote. A plugin declares data and shape; core renders.""" + from plugins.geenforce.plugin import GeEnforcePlugin + + widget = GeEnforcePlugin().get_dashboard_widgets()[0] + assert 'component' not in widget + assert widget['render'] == 'exceptions' + assert widget['permission'] == 'geenforce.manage' + # Empty cards must shrink, or the board becomes wallpaper. + assert widget['empty'] == 'hide' + assert widget['map']['link'] == '/pcs/{computerid}'