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 @@
| Host | PC type | Received | Version | +Host | Asset | PC type | Received | Version | Status | Installed | Skipped | Failed | -Last check-in | + | Backup | Last check-in | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {{ report.hostname }} | +
+ |
+
+
+ {{ report.location }}
+ |
{{ report.scopename }} | @@ -53,12 +88,22 @@ | {{ report.installed }} | {{ report.skipped }} | {{ report.failed }} | + +
+
+ {{ formatDate(report.backuplastseen) }}
+ {{ report.backupkind }}
+
+ -
+ |
{{ formatDate(report.lastcheckin || report.receivedat) }} | ||||||||
| No reports yet. | |||||||||||||||||
| No reports yet. | |||||||||||||||||