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.
This commit is contained in:
cproudlock
2026-08-12 16:31:24 -04:00
parent 523e3e4ecc
commit 598c2c98bc
2 changed files with 225 additions and 4 deletions

View File

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

View File

@@ -34,14 +34,49 @@
<table>
<thead>
<tr>
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
<th>Host</th><th>Asset</th><th>PC type</th><th>Received</th><th>Version</th>
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
<th>Last check-in</th><th></th>
<th>Backup</th><th>Last check-in</th><th></th>
</tr>
</thead>
<tbody>
<tr v-for="report in reports" :key="report.reportid">
<td>{{ report.hostname }}</td>
<td class="host-cell">
<router-link v-if="report.computerid" :to="`/pcs/${report.computerid}`">
{{ report.hostname }}
</router-link>
<span v-else>{{ report.hostname }}</span>
<!-- Coordinates give the same floor-plan preview the asset's own
page uses. No coordinates, no icon - an icon that opened an
empty map would be worse than none. -->
<LocationMapTooltip
v-if="report.mapx != null && report.mapy != null"
:left="report.mapx" :top="report.mapy"
:machineName="report.hostname"
>
<span class="map-pin" :title="report.location || 'On the floor plan'">&#9678;</span>
</LocationMapTooltip>
</td>
<td class="asset-cell">
<!-- What this PC IS or DRIVES, whichever applies. A part-marker
or bay PC shows its machine; a measuring-tool PC its tool;
a display its role. -->
<router-link v-if="report.machineassetid" class="asset-chip"
:to="`/machines/${report.machineassetid}`"
title="Machine this PC controls">
{{ report.machinenumber }}
</router-link>
<router-link v-if="report.toolassetid" class="asset-chip"
:to="`/measuringtools/${report.toolassetid}`"
title="Measuring tool this PC controls">
{{ report.toolassetnumber }}
</router-link>
<span v-if="report.displayrole" class="asset-chip muted"
title="Display role">{{ report.displayrole }}</span>
<span v-if="!report.machineassetid && !report.toolassetid && !report.displayrole"
class="muted">{{ report.assetnumber || '-' }}</span>
<div v-if="report.location" class="asset-location muted">{{ report.location }}</div>
</td>
<td>{{ report.scopename }}</td>
<td>
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
@@ -53,12 +88,22 @@
<td>{{ report.installed }}</td>
<td class="muted">{{ report.skipped }}</td>
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
<!-- Last CONFIRMED, not last changed: an unchanged config writes
no revision, so a stable machine is healthy with an old
revision. What matters is whether the backup still runs. -->
<td class="muted">
<template v-if="report.backuplastseen">
{{ formatDate(report.backuplastseen) }}
<div class="backup-kind">{{ report.backupkind }}</div>
</template>
<span v-else>-</span>
</td>
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
</td>
</tr>
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
<tr v-if="!reports.length"><td colspan="12" class="empty">No reports yet.</td></tr>
</tbody>
</table>
</div>
@@ -105,6 +150,7 @@
<script setup>
import { ref } from 'vue'
import api from '@/api'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
const reports = ref([])
const detail = ref(null)
@@ -163,4 +209,17 @@ load()
padding: 0.25rem 0.5rem;
}
.modal-close:hover { color: var(--text); }
.host-cell { display: flex; align-items: center; gap: 0.35rem; }
.map-pin { cursor: pointer; color: var(--primary); font-size: 0.95rem; line-height: 1; }
.asset-cell { white-space: nowrap; }
.asset-chip {
display: inline-block;
font-size: 0.8rem;
padding: 0.05rem 0.4rem;
margin-right: 0.25rem;
border: 1px solid var(--border);
border-radius: 3px;
}
.asset-location { font-size: 0.75rem; margin-top: 0.15rem; }
.backup-kind { font-size: 0.72rem; }
</style>