Enforcement reports with asset context and backup state; manifest editor spacing
This commit is contained in:
@@ -909,6 +909,150 @@ def _current_published_version(scopename, phase):
|
|||||||
return published.versionnumber if published else None
|
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'])
|
@geenforce_bp.route('/reports', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
@@ -929,6 +1073,9 @@ def list_reports():
|
|||||||
reports = query.order_by(
|
reports = query.order_by(
|
||||||
ManifestEnforcementReport.receivedat.desc()).all()
|
ManifestEnforcementReport.receivedat.desc()).all()
|
||||||
|
|
||||||
|
facts = _asset_facts({report.hostname for report in reports
|
||||||
|
if report.hostname})
|
||||||
|
|
||||||
latest_cache = {}
|
latest_cache = {}
|
||||||
data = []
|
data = []
|
||||||
for report in reports:
|
for report in reports:
|
||||||
@@ -936,9 +1083,24 @@ def list_reports():
|
|||||||
if key not in latest_cache:
|
if key not in latest_cache:
|
||||||
latest_cache[key] = _current_published_version(*key)
|
latest_cache[key] = _current_published_version(*key)
|
||||||
latest = latest_cache[key]
|
latest = latest_cache[key]
|
||||||
|
known = facts.get((report.hostname or '').lower(), {})
|
||||||
data.append({
|
data.append({
|
||||||
'reportid': report.reportid,
|
'reportid': report.reportid,
|
||||||
'hostname': report.hostname,
|
'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,
|
'scopename': report.scopename,
|
||||||
'phase': report.phase,
|
'phase': report.phase,
|
||||||
'appliedversion': report.appliedversion,
|
'appliedversion': report.appliedversion,
|
||||||
|
|||||||
@@ -34,14 +34,49 @@
|
|||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>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>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr v-for="report in reports" :key="report.reportid">
|
<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'">◎</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>{{ report.scopename }}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
|
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
|
||||||
@@ -53,12 +88,22 @@
|
|||||||
<td>{{ report.installed }}</td>
|
<td>{{ report.installed }}</td>
|
||||||
<td class="muted">{{ report.skipped }}</td>
|
<td class="muted">{{ report.skipped }}</td>
|
||||||
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</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="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
|
||||||
<td class="actions">
|
<td class="actions">
|
||||||
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
|
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,6 +150,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
|
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
|
||||||
|
|
||||||
const reports = ref([])
|
const reports = ref([])
|
||||||
const detail = ref(null)
|
const detail = ref(null)
|
||||||
@@ -163,4 +209,17 @@ load()
|
|||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
}
|
}
|
||||||
.modal-close:hover { color: var(--text); }
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -945,18 +945,41 @@ loadApplications()
|
|||||||
/* Modals: wider entry/preview, still responsive (global max-width + width:100%). */
|
/* Modals: wider entry/preview, still responsive (global max-width + width:100%). */
|
||||||
.modal-entry { max-width: min(1100px, 96vw); }
|
.modal-entry { max-width: min(1100px, 96vw); }
|
||||||
.toggle-all { font-weight: 400; font-size: 0.8rem; }
|
.toggle-all { font-weight: 400; font-size: 0.8rem; }
|
||||||
.entry-name { font-weight: 500; }
|
.entry-name { font-weight: 500; line-height: 1.35; }
|
||||||
.entry-desc { font-size: 0.78rem; margin-top: 0.15rem; overflow-wrap: anywhere; }
|
.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; }
|
.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
|
/* Fixed layout so the Entry column flexes and the button columns never force
|
||||||
a horizontal scroll. */
|
a horizontal scroll. */
|
||||||
.scope-detail .entries-table { width: 100%; table-layout: fixed; }
|
.scope-detail .entries-table { width: 100%; table-layout: fixed; }
|
||||||
.entries-table th.col-order, .entries-table td.order-cell { width: 58px; }
|
/* Every column was sized to the button text with nothing to spare, so the
|
||||||
.entries-table th.col-type { width: 64px; }
|
action buttons wrapped out of their cell and landed on the description. The
|
||||||
.entries-table th.col-actions, .entries-table td.row-actions { width: 128px; }
|
widths below fit Edit + Delete on one line, and nowrap keeps them there. */
|
||||||
.order-cell { display: flex; flex-direction: column; gap: 0.2rem; }
|
.entries-table th.col-order, .entries-table td.order-cell { width: 84px; }
|
||||||
.order-cell .btn { padding: 0.1rem 0.3rem; }
|
.entries-table th.col-type { width: 76px; }
|
||||||
.row-actions { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
.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);
|
.help-panel { margin: 0 0 1rem 0; border: 1px solid var(--border);
|
||||||
border-radius: 6px; padding: 0.5rem 0.75rem; background: var(--bg-card); }
|
border-radius: 6px; padding: 0.5rem 0.75rem; background: var(--bg-card); }
|
||||||
.help-panel summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; }
|
.help-panel summary { cursor: pointer; font-weight: 600; font-size: 0.9rem; }
|
||||||
|
|||||||
Reference in New Issue
Block a user