Enforcement reports with asset context and backup state; manifest editor spacing

This commit is contained in:
2026-08-12 16:35:46 -04:00
parent ee7f8640d1
commit e8f33cffa1
3 changed files with 256 additions and 12 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>

View File

@@ -945,18 +945,41 @@ loadApplications()
/* Modals: wider entry/preview, still responsive (global max-width + width:100%). */
.modal-entry { max-width: min(1100px, 96vw); }
.toggle-all { font-weight: 400; font-size: 0.8rem; }
.entry-name { font-weight: 500; }
.entry-desc { font-size: 0.78rem; margin-top: 0.15rem; overflow-wrap: anywhere; }
.entry-name { font-weight: 500; line-height: 1.35; }
.entry-desc {
font-size: 0.78rem;
margin-top: 0.3rem;
line-height: 1.45;
overflow-wrap: anywhere;
}
.scope-summary { margin: 0 0 0.75rem 0; font-size: 0.85rem; }
/* Fixed layout so the Entry column flexes and the button columns never force
a horizontal scroll. */
.scope-detail .entries-table { width: 100%; table-layout: fixed; }
.entries-table th.col-order, .entries-table td.order-cell { width: 58px; }
.entries-table th.col-type { width: 64px; }
.entries-table th.col-actions, .entries-table td.row-actions { width: 128px; }
.order-cell { display: flex; flex-direction: column; gap: 0.2rem; }
.order-cell .btn { padding: 0.1rem 0.3rem; }
.row-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
/* Every column was sized to the button text with nothing to spare, so the
action buttons wrapped out of their cell and landed on the description. The
widths below fit Edit + Delete on one line, and nowrap keeps them there. */
.entries-table th.col-order, .entries-table td.order-cell { width: 84px; }
.entries-table th.col-type { width: 76px; }
.entries-table th.col-actions, .entries-table td.row-actions { width: 172px; }
.entries-table td {
/* A row is two lines of text plus stacked buttons; without vertical padding
and top alignment the rows run into each other. */
padding: 0.6rem 0.6rem;
vertical-align: top;
}
.order-cell { display: flex; flex-direction: column; gap: 0.3rem; }
.order-cell .btn {
padding: 0.25rem 0.5rem;
width: 100%;
}
.row-actions {
display: flex;
gap: 0.4rem;
flex-wrap: nowrap;
white-space: nowrap;
}
.row-actions .btn { padding: 0.25rem 0.6rem; }
.help-panel { margin: 0 0 1rem 0; border: 1px solid var(--border);
border-radius: 6px; padding: 0.5rem 0.75rem; background: var(--bg-card); }
.help-panel summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; }