geenforce: the fleet table links where it says, and judges backups instead of dating them
Two fixes to the same table, in the same regions of the same files. ASSET LINK POINTED AT THE WRONG RECORD. The Asset chip linked /machines/<assetid>, but /machines/:id keys on machineid - the plugin extension id - as MachineDetail itself does everywhere. So the link landed on whichever machine happened to carry that number: a wrong page that looks right, which is worse than a 404. Same for /measuringtools/. The API now returns machinepluginid / toolpluginid beside the asset ids and the view links on those. Both lookups are import-guarded, and with no plugin id the number renders as plain text rather than a link that misleads. AssetRelationships already resolved this correctly; this brings the reports table in line. BACKUP COLUMN READ AS NEGLECT. It showed a raw date, and a revision is only written when the config CHANGES - dedup means a machine stable for months has a months-old newest revision and is perfectly healthy. The column already used lastseenat, the last time the collector CONFIRMED the config, but a bare timestamp says "nothing has happened since", which at the default 24h collection interval IS the healthy steady state. It made a working system look stalled and made the reader do arithmetic against a setting they would have to go and find. It now returns backupok and shows a badge naming the kind, green when confirmed recently, red when not, with the date and an explanation in the hover. backupok is tri-state on purpose: null means no revision at all, and renders as NO badge rather than a green one, because "never seen" must not read as healthy. The threshold is the backups plugin's own backups_staledays, read through its service so there is one definition of stale rather than a second drifting here. Nothing in the badge is kind-specific, so a backup kind added later inherits it by existing. docs/BACKUP-KINDS.md records that, the BackupKind contract, and why the rule is time-based rather than per-kind.
This commit is contained in:
@@ -955,9 +955,12 @@ def _asset_facts(hostnames):
|
||||
'mapx': asset.mapx,
|
||||
'mapy': asset.mapy,
|
||||
'machinenumber': None, 'machineassetid': None,
|
||||
'machinepluginid': None,
|
||||
'toolassetnumber': None, 'toolassetid': None,
|
||||
'toolpluginid': None,
|
||||
'displayrole': None,
|
||||
'backupkind': None, 'backuplastseen': None,
|
||||
'backuplastseenat': None,
|
||||
}
|
||||
|
||||
_attach_controlled_assets(facts, assetidtohost)
|
||||
@@ -993,11 +996,24 @@ def _attach_controlled_assets(facts, assetidtohost):
|
||||
AssetRelationship.sourceassetid.in_(assetidtohost))
|
||||
.all())
|
||||
|
||||
toolassetids = set()
|
||||
# assetid -> the PLUGIN extension id the detail routes key on. /machines/:id
|
||||
# and /measuringtools/:id take machineid / measuringtoolid, NOT the core
|
||||
# assetid; linking with the assetid lands on whichever record happens to
|
||||
# share that number, which looks plausible and is wrong. Frontend
|
||||
# getAssetRoute resolves the same way.
|
||||
toolids = {}
|
||||
try:
|
||||
from plugins.measuringtools.models import MeasuringTool
|
||||
toolassetids = {row.assetid for row in
|
||||
MeasuringTool.query.with_entities(MeasuringTool.assetid)}
|
||||
toolids = {row.assetid: row.measuringtoolid for row in
|
||||
MeasuringTool.query.with_entities(
|
||||
MeasuringTool.assetid, MeasuringTool.measuringtoolid)}
|
||||
except ImportError:
|
||||
pass
|
||||
machineids = {}
|
||||
try:
|
||||
from plugins.machines.models import Machine
|
||||
machineids = {row.assetid: row.machineid for row in
|
||||
Machine.query.with_entities(Machine.assetid, Machine.machineid)}
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -1005,12 +1021,17 @@ def _attach_controlled_assets(facts, assetidtohost):
|
||||
entry = facts.get(assetidtohost.get(sourceassetid))
|
||||
if not entry:
|
||||
continue
|
||||
if asset.assetid in toolassetids:
|
||||
if asset.assetid in toolids:
|
||||
entry['toolassetnumber'] = asset.assetnumber
|
||||
entry['toolassetid'] = asset.assetid
|
||||
entry['toolpluginid'] = toolids.get(asset.assetid)
|
||||
else:
|
||||
entry['machinenumber'] = asset.assetnumber
|
||||
entry['machineassetid'] = asset.assetid
|
||||
# None when the machines plugin is not installed or the asset has no
|
||||
# extension row: the frontend then renders the number without a link
|
||||
# rather than a link that goes somewhere wrong.
|
||||
entry['machinepluginid'] = machineids.get(asset.assetid)
|
||||
|
||||
|
||||
def _attach_display_roles(facts, hostnames):
|
||||
@@ -1059,6 +1080,51 @@ def _attach_backup_state(facts, hostnames):
|
||||
entry['backuplastseen'] = (
|
||||
revision.lastseenat.isoformat() + 'Z'
|
||||
if revision.lastseenat else None)
|
||||
# Raw value kept beside the wire string so the verdict compares
|
||||
# datetimes rather than re-parsing its own output.
|
||||
entry['backuplastseenat'] = revision.lastseenat
|
||||
|
||||
|
||||
def _backup_ok(lastseenat, cutoff):
|
||||
"""Tri-state verdict for one PC's backup: True good, False stale, None unknown.
|
||||
|
||||
A BOOLEAN rather than a date, because the question is "is this backed up?"
|
||||
and every backup kind answers it the same way. Adding a new kind needs no
|
||||
change here - it reports a revision like the others and inherits the badge.
|
||||
|
||||
None means there is nothing to judge: no backup revision for this host at
|
||||
all, or the staleness check is disabled. The caller shows no badge rather
|
||||
than a green one, since "never seen" must not read as healthy.
|
||||
"""
|
||||
if cutoff is None or lastseenat is None:
|
||||
return None
|
||||
return lastseenat >= cutoff
|
||||
|
||||
|
||||
def _backup_stale_cutoff():
|
||||
"""(cutoff datetime, threshold days) for 'this PC's backup has gone quiet'.
|
||||
|
||||
A raw date makes the reader do the arithmetic, and the arithmetic is not
|
||||
obvious: the collector only ATTEMPTS every backups_intervalhours (24 by
|
||||
default), so a confirmation from yesterday is the healthy steady state, not
|
||||
a warning. What answers the question is the verdict.
|
||||
|
||||
The threshold is the backups plugin's own backups_staledays, read through
|
||||
its service so there is ONE definition of stale rather than a second one
|
||||
drifting here. Returns (None, 0) when the plugin is absent or the check is
|
||||
disabled, and callers then show the date alone.
|
||||
"""
|
||||
try:
|
||||
from plugins.backups.services import staleness
|
||||
except ImportError:
|
||||
return None, 0
|
||||
try:
|
||||
days = int(staleness.staledays())
|
||||
except (TypeError, ValueError):
|
||||
return None, 0
|
||||
if days <= 0:
|
||||
return None, 0
|
||||
return service._utcnow() - timedelta(days=days), days
|
||||
|
||||
|
||||
def _report_stale_cutoff():
|
||||
@@ -1108,6 +1174,7 @@ def list_reports():
|
||||
if report.hostname})
|
||||
|
||||
stalecutoff, stalafter = _report_stale_cutoff()
|
||||
backupcutoff, backupafterdays = _backup_stale_cutoff()
|
||||
latest_cache = {}
|
||||
data = []
|
||||
for report in reports:
|
||||
@@ -1142,13 +1209,23 @@ def list_reports():
|
||||
'mapy': known.get('mapy'),
|
||||
'machinenumber': known.get('machinenumber'),
|
||||
'machineassetid': known.get('machineassetid'),
|
||||
'machinepluginid': known.get('machinepluginid'),
|
||||
'toolassetnumber': known.get('toolassetnumber'),
|
||||
'toolassetid': known.get('toolassetid'),
|
||||
'toolpluginid': known.get('toolpluginid'),
|
||||
# Reported by the device wins; the DashboardDefault mapping is a
|
||||
# fallback for hosts on an older client that does not send it.
|
||||
'displayrole': report.subtype or known.get('displayrole'),
|
||||
'backupkind': known.get('backupkind'),
|
||||
'backuplastseen': known.get('backuplastseen'),
|
||||
# VERIFIED, not "last backup taken". Dedup means an unchanged config
|
||||
# writes no new revision, so lastseenat is the last time the
|
||||
# collector CHECKED and found the backup intact - which is the
|
||||
# question. backupok says whether that check is recent enough to
|
||||
# trust; the date alone reads as "nothing has happened since", which
|
||||
# is the healthy steady state and looks like neglect.
|
||||
'backupok': _backup_ok(known.get('backuplastseenat'), backupcutoff),
|
||||
'backupstaleafterdays': backupafterdays,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
|
||||
@@ -61,23 +61,33 @@
|
||||
<!-- 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}`"
|
||||
<!-- /machines/:id and /measuringtools/:id key on the PLUGIN
|
||||
extension id, NOT the core assetid. Linking with the assetid
|
||||
lands on whichever record happens to share that number - a
|
||||
wrong page that looks right. No plugin id means the plugin
|
||||
is not installed or the asset has no extension row, so show
|
||||
the number as plain text rather than a link that misleads. -->
|
||||
<router-link v-if="report.machinepluginid" class="asset-chip"
|
||||
:to="`/machines/${report.machinepluginid}`"
|
||||
title="Machine this PC controls">
|
||||
{{ report.machinenumber }}
|
||||
</router-link>
|
||||
<router-link v-if="report.toolassetid" class="asset-chip"
|
||||
:to="`/measuringtools/${report.toolassetid}`"
|
||||
<span v-else-if="report.machinenumber" class="asset-chip muted"
|
||||
title="Machine this PC controls">{{ report.machinenumber }}</span>
|
||||
<router-link v-if="report.toolpluginid" class="asset-chip"
|
||||
:to="`/measuringtools/${report.toolpluginid}`"
|
||||
title="Measuring tool this PC controls">
|
||||
{{ report.toolassetnumber }}
|
||||
</router-link>
|
||||
<span v-else-if="report.toolassetnumber" class="asset-chip muted"
|
||||
title="Measuring tool this PC controls">{{ report.toolassetnumber }}</span>
|
||||
<span v-if="report.displayrole" class="asset-chip muted"
|
||||
title="Display role">{{ report.displayrole }}</span>
|
||||
<!-- Deliberately NOT the PC's own asset number: the collector
|
||||
stores a PC's hostname AS its assetnumber, so echoing it
|
||||
here would just repeat the Host column. Blank means this PC
|
||||
drives nothing and is not a display. -->
|
||||
<span v-if="!report.machineassetid && !report.toolassetid && !report.displayrole"
|
||||
<span v-if="!report.machinenumber && !report.toolassetnumber && !report.displayrole"
|
||||
class="muted">-</span>
|
||||
<div v-if="report.location" class="asset-location muted">{{ report.location }}</div>
|
||||
</td>
|
||||
@@ -99,15 +109,19 @@
|
||||
<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>
|
||||
<!-- A VERDICT, not a date. An unchanged config writes no new
|
||||
revision, so the timestamp is the last time the collector
|
||||
CHECKED and found the backup intact - which read as neglect
|
||||
when shown raw, because the healthy steady state is a
|
||||
day-old confirmation. The badge names the kind and colours
|
||||
it good/stale; the date moves to the tooltip. Any backup
|
||||
kind added later inherits this with no change here. -->
|
||||
<td>
|
||||
<span v-if="report.backupkind"
|
||||
class="badge"
|
||||
:class="backupClass(report)"
|
||||
:title="backupTitle(report)">{{ report.backupkind }}</span>
|
||||
<span v-else class="muted">-</span>
|
||||
</td>
|
||||
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
|
||||
<td class="actions">
|
||||
@@ -191,6 +205,27 @@ function statusClass(status) {
|
||||
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
|
||||
}
|
||||
|
||||
// null verdict = nothing to judge (no revision, or the check is disabled).
|
||||
// Deliberately NOT green: "never seen" must not read as healthy.
|
||||
function backupClass(report) {
|
||||
if (report.backupok === true) return 'badge-success'
|
||||
if (report.backupok === false) return 'badge-danger'
|
||||
return ''
|
||||
}
|
||||
|
||||
function backupTitle(report) {
|
||||
const seen = report.backuplastseen ? formatDate(report.backuplastseen) : 'never'
|
||||
if (report.backupok === true) {
|
||||
return `${report.backupkind}: checked and verified ${seen}.`
|
||||
+ ' Config unchanged since, which is why the date does not move.'
|
||||
}
|
||||
if (report.backupok === false) {
|
||||
return `${report.backupkind}: last confirmed ${seen}, more than`
|
||||
+ ` ${report.backupstaleafterdays} day(s) ago - the backup has stopped running.`
|
||||
}
|
||||
return `${report.backupkind}: last confirmed ${seen}.`
|
||||
}
|
||||
|
||||
function staleTitle(report) {
|
||||
const received = report.receivedat ? formatDate(report.receivedat) : 'never'
|
||||
const checkin = report.lastcheckin ? formatDate(report.lastcheckin) : 'not reported'
|
||||
|
||||
Reference in New Issue
Block a user