From 598c2c98bcc6778cedaaa8592e8c1cdab21c4c40 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Wed, 12 Aug 2026 16:31:24 -0400 Subject: [PATCH] geenforce: reports say what the PC is, where, and whether it is backed up A report row carried a hostname and some counts. Everything an operator wants next hangs off the asset behind that host, and none of it was there. Host now links to its PC page. Beside it, what the PC IS or DRIVES: the machine number for a bay or part-marker PC, the measuring tool for a tool PC, the role for a display - each linking to that asset's own page. A map pin appears only when the asset has coordinates and hovers to the same floor-plan preview the asset pages use; an icon that opened an empty map would be worse than no icon. The backup column is LAST CONFIRMED, not last changed. Dedup means an unchanged config writes no revision, so a machine stable for six months has a six-month-old newest revision and is perfectly healthy - the one to worry about is the machine whose backup stopped running. It reads lastseenat and names the kind rather than assuming ntlars, since udc/file kinds on the share are coming. Resolution is bulk, never per row: this table shows the whole fleet, so a lookup inside the loop would be one query per PC. It reads the collector's existing 'controls' relationship rather than re-deriving which machine a PC drives - that same resolution living in two places is what put a wrong subtype filter on the map. Every plugin it touches is optional, so each lookup is ImportError-guarded and a lean build renders the table without those columns. A host ShopDB has no asset for still shows: the enforcement result is real even when the inventory is behind. --- plugins/geenforce/api/routes.py | 162 ++++++++++++++++++ .../frontend/views/EnforcementReports.vue | 67 +++++++- 2 files changed, 225 insertions(+), 4 deletions(-) diff --git a/plugins/geenforce/api/routes.py b/plugins/geenforce/api/routes.py index 200fc71..1be975b 100644 --- a/plugins/geenforce/api/routes.py +++ b/plugins/geenforce/api/routes.py @@ -909,6 +909,150 @@ def _current_published_version(scopename, phase): return published.versionnumber if published else None +def _asset_facts(hostnames): + """What ShopDB knows about each reporting host, resolved in bulk. + + A report row only carries a hostname; everything an operator wants next - + which machine that PC drives, which measuring tool it is, where it sits, + whether its config is still being backed up - hangs off the asset behind it. + + Bulk queries, never per row: this table shows the whole fleet, so a lookup + inside the loop would be one query per PC. Every plugin here is optional + (ADR-013), hence the ImportError guards - a lean build without computers + still renders the table, just without these columns. + + Returns {lowercased hostname: {...}}. + """ + facts = {} + if not hostnames: + return facts + + try: + from plugins.computers.models import Computer + except ImportError: + return facts + + from shopdb.api import Asset + + rows = (db.session.query(Computer, Asset) + .join(Asset, Asset.assetid == Computer.assetid) + .filter(Computer.hostname.in_(hostnames)) + .all()) + + assetidtohost = {} + for computer, asset in rows: + key = (computer.hostname or '').lower() + assetidtohost[asset.assetid] = key + facts[key] = { + 'computerid': computer.computerid, + 'assetid': asset.assetid, + 'assetnumber': asset.assetnumber, + 'location': (asset.location.locationname if asset.location else None), + 'mapx': asset.mapx, + 'mapy': asset.mapy, + 'machinenumber': None, 'machineassetid': None, + 'toolassetnumber': None, 'toolassetid': None, + 'displayrole': None, + 'backupkind': None, 'backuplastseen': None, + } + + _attach_controlled_assets(facts, assetidtohost) + _attach_display_roles(facts, hostnames) + _attach_backup_state(facts, hostnames) + return facts + + +def _attach_controlled_assets(facts, assetidtohost): + """Machine number / measuring-tool asset # for the asset each PC controls. + + The collector records this as a 'controls' relationship, so read that rather + than re-deriving it - the same resolution living in two places is what put a + wrong subtype filter on the map. + """ + if not assetidtohost: + return + try: + from shopdb.api import Asset, AssetRelationship, RelationshipType + except ImportError: + return + + controls = RelationshipType.query.filter_by( + relationshiptype='controls').first() + if not controls: + return + + target = db.aliased(Asset) + rows = (db.session.query(AssetRelationship.sourceassetid, target) + .join(target, target.assetid == AssetRelationship.targetassetid) + .filter(AssetRelationship.relationshiptypeid == controls.relationshiptypeid, + AssetRelationship.isactive.is_(True), + AssetRelationship.sourceassetid.in_(assetidtohost)) + .all()) + + toolassetids = set() + try: + from plugins.measuringtools.models import MeasuringTool + toolassetids = {row.assetid for row in + MeasuringTool.query.with_entities(MeasuringTool.assetid)} + except ImportError: + pass + + for sourceassetid, asset in rows: + entry = facts.get(assetidtohost.get(sourceassetid)) + if not entry: + continue + if asset.assetid in toolassetids: + entry['toolassetnumber'] = asset.assetnumber + entry['toolassetid'] = asset.assetid + else: + entry['machinenumber'] = asset.assetnumber + entry['machineassetid'] = asset.assetid + + +def _attach_display_roles(facts, hostnames): + """Dashboard / Lobby / parts kiosk, from the display-role mapping. + + ShopDB's mirror of the display-type.txt each kiosk reads at startup. + """ + try: + from shopdb.core.models.dashboarddefault import DashboardDefault + except ImportError: + return + lowered = {name.lower() for name in hostnames} + for default in DashboardDefault.query.filter_by(isactive=True).all(): + host = (default.fqdn or '').split('.')[0].lower() + if host and host in lowered and facts.get(host): + facts[host]['displayrole'] = default.displayrole + + +def _attach_backup_state(facts, hostnames): + """Whether this PC's config is still being backed up, and of what kind. + + Keyed on lastseenat, NOT the newest revision date. Dedup means an unchanged + config writes no revision, so a machine stable for six months has a + six-month-old newest revision and is perfectly healthy; the one to worry + about is the machine whose backup stopped running. sourcehostname is the PC + the config was read off, which is exactly the host reporting here. + """ + try: + from plugins.backups.models import BackupRevision + except ImportError: + return + rows = (BackupRevision.query + .filter(BackupRevision.sourcehostname.in_(hostnames)) + .order_by(BackupRevision.lastseenat.desc().nullslast()) + .all()) + for revision in rows: + key = (revision.sourcehostname or '').lower() + entry = facts.get(key) + # First row per host wins: ordered newest-confirmed first. + if entry and entry['backuplastseen'] is None: + entry['backupkind'] = revision.backupkind + entry['backuplastseen'] = ( + revision.lastseenat.isoformat() + 'Z' + if revision.lastseenat else None) + + @geenforce_bp.route('/reports', methods=['GET']) @jwt_required() @require_permission('geenforce.manage') @@ -929,6 +1073,9 @@ def list_reports(): reports = query.order_by( ManifestEnforcementReport.receivedat.desc()).all() + facts = _asset_facts({report.hostname for report in reports + if report.hostname}) + latest_cache = {} data = [] for report in reports: @@ -936,9 +1083,24 @@ def list_reports(): if key not in latest_cache: latest_cache[key] = _current_published_version(*key) latest = latest_cache[key] + known = facts.get((report.hostname or '').lower(), {}) data.append({ 'reportid': report.reportid, 'hostname': report.hostname, + # A host ShopDB has no asset for still shows: the enforcement result + # is real even when the inventory is behind. + 'computerid': known.get('computerid'), + 'assetnumber': known.get('assetnumber'), + 'location': known.get('location'), + 'mapx': known.get('mapx'), + 'mapy': known.get('mapy'), + 'machinenumber': known.get('machinenumber'), + 'machineassetid': known.get('machineassetid'), + 'toolassetnumber': known.get('toolassetnumber'), + 'toolassetid': known.get('toolassetid'), + 'displayrole': known.get('displayrole'), + 'backupkind': known.get('backupkind'), + 'backuplastseen': known.get('backuplastseen'), 'scopename': report.scopename, 'phase': report.phase, 'appliedversion': report.appliedversion, diff --git a/plugins/geenforce/frontend/views/EnforcementReports.vue b/plugins/geenforce/frontend/views/EnforcementReports.vue index c730e84..bb43588 100644 --- a/plugins/geenforce/frontend/views/EnforcementReports.vue +++ b/plugins/geenforce/frontend/views/EnforcementReports.vue @@ -34,14 +34,49 @@ - + - + - + + + + - +
HostPC typeReceivedVersionHostAssetPC typeReceivedVersion StatusInstalledSkippedFailedLast check-inBackupLast check-in
{{ report.hostname }} + + {{ report.hostname }} + + {{ report.hostname }} + + + + + + + + {{ report.machinenumber }} + + + {{ report.toolassetnumber }} + + {{ report.displayrole }} + {{ report.assetnumber || '-' }} +
{{ report.location }}
+
{{ report.scopename }} @@ -53,12 +88,22 @@ {{ report.installed }} {{ report.skipped }} {{ report.failed }} + + - + {{ formatDate(report.lastcheckin || report.receivedat) }}
No reports yet.
No reports yet.
@@ -105,6 +150,7 @@