Shadow task fixes, asset link ids, backup verdict

This commit is contained in:
2026-08-13 13:20:24 -04:00
parent 6e0d8aec4e
commit 376b8df0fb
10 changed files with 1036 additions and 150 deletions

View File

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