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:
@@ -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,
|
||||
|
||||
@@ -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'">◎</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>
|
||||
|
||||
Reference in New Issue
Block a user