geenforce: first dashboard card, and the widget contract it proves
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

Wave one of the dashboard proposal, built as a vertical slice so the contract
is proven by something real before the other five cards follow.

GET /api/geenforce/dashboard/failures lists entries that FAILED on their PC's
most recent enforcement cycle. 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 count, so a failure that
has since been fixed clears itself instead of needing dismissing. Hostnames
resolve to computerids in one query so each row links to the PC, and a PC
shopdb does not know still appears - the failure is real even when the
inventory is behind, and that is the bay most likely to be misconfigured.

The data has been there all along. The only way to see any of it was to open
one PC's report modal, one PC at a time.

The widget declaration is the contract change. The old shape named a Vue
component per widget, which cannot survive a lean build where a plugin's
component may never be staged into the bundle - which is exactly why five
plugins declare widgets pointing at components nobody ever wrote. This declares
data, a generic renderer, a permission and a link template, the way ADR-010
already does for asset panels. A test asserts no 'component' key, so the old
shape cannot creep back.

empty: hide is part of the contract, not decoration. A card reporting "nothing
wrong" daily teaches people to stop reading the page, which is how a fleet log
reached 3,234 lines with 17 that mattered.

Frontend rendering comes next; the endpoint and declaration stand alone and
change nothing that exists.
This commit is contained in:
cproudlock
2026-08-11 13:01:04 -04:00
parent 02d7619b1a
commit 8b50e6fe2a
4 changed files with 216 additions and 2 deletions

View File

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

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 [

View File

@@ -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}'