backups: show that a check happened, not just that a change did
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

lastseenat already recorded it and the API already returned it; nothing
displayed it, so from the UI a healthy machine still looked abandoned - one
revision from last spring and no sign anything had looked at it since.

The history page gains a Last verified column beside Captured, and the asset
panel a Verified field. Only the CURRENT revision carries one: an older
revision was superseded, so saying it was verified today would be false - what
was verified is the configuration the PC holds now.

A current revision with no check yet says "not yet checked" rather than showing
a blank or borrowing the captured date. That state is real and temporary: the
column is new, so every chain reports it until its PC next posts.
This commit is contained in:
cproudlock
2026-08-11 15:00:25 -04:00
parent e0e4cce8bd
commit 294ddbb38e
4 changed files with 51 additions and 0 deletions

View File

@@ -12,6 +12,13 @@ ADR-007 and ADR-002.
### Added
- Backup history distinguishes CAPTURED from LAST VERIFIED. Captured is when a
configuration was written; verified is when a PC last posted it and the hash
still matched. They differ by design, because an unchanged config writes no
new revision - so a machine checked daily for a year shows one revision from
last spring, and previously read as abandoned. Shown on the history page and
on the asset panel; only the current revision carries it, since an older one
is not what the PC holds now.
- A backup now records that it was CHECKED, not only that it changed. Dedup
means an unchanged configuration writes no revision, so the stored timestamps
moved only on a change: a machine whose settings had been stable for six

View File

@@ -20,6 +20,7 @@
<thead>
<tr>
<th>Captured</th>
<th>Last verified</th>
<th>Kind</th>
<th>From PC</th>
<th>Hash</th>
@@ -34,6 +35,22 @@
{{ formatWhen(rev.collectedat || rev.createdat) }}
<span v-if="rev.islatest" class="bh-badge">current</span>
</td>
<!-- CAPTURED is when this configuration was written; LAST VERIFIED is
when a PC last posted it and the hash still matched. They differ
by design: an unchanged config writes no new revision, so a
machine can be checked daily for a year and still show one
revision from last spring. Without this column that machine
looked abandoned. Only the current revision is verified - an
older one was superseded, so it is not what the PC holds now. -->
<td>
<template v-if="rev.islatest && rev.lastseenat">
{{ formatWhen(rev.lastseenat) }}
</template>
<span v-else-if="rev.islatest" class="bh-muted"
title="No check recorded since this was introduced. The next
collection will record one.">not yet checked</span>
<span v-else class="bh-muted">-</span>
</td>
<td>{{ rev.backupkind }}</td>
<td>{{ rev.sourcehostname || '-' }}</td>
<td class="bh-mono">{{ rev.shorthash }}</td>

View File

@@ -101,6 +101,12 @@ class BackupsPlugin(BasePlugin):
'title': 'label',
'meta': [
{'key': 'collectedat', 'label': 'Captured', 'format': 'date'},
# Captured is when this config was WRITTEN; verified is
# when a PC last posted it and the hash still matched.
# An unchanged config writes no revision, so without
# this a machine checked daily for a year shows one
# entry from last spring and reads as abandoned.
{'key': 'lastseenat', 'label': 'Verified', 'format': 'date'},
{'key': 'sourcehostname', 'label': 'From'},
{'key': 'shorthash', 'label': 'Hash', 'mono': True},
],

View File

@@ -960,3 +960,24 @@ def test_the_threshold_is_a_setting_and_zero_disables_the_card(bk_app, bk_plugin
{'value': '0'})
_db.session.commit()
assert stalechains() == []
def test_the_history_reports_when_a_config_was_last_verified(bk_app, bk_plugin):
"""A machine checked daily for a year still shows one revision, because an
unchanged config writes none. The API has to say the check happened or that
machine reads as abandoned."""
from plugins.backups.models import BackupRevision
with bk_app.app_context():
result = bk_plugin.apply_collector_payload(_payload())
revision = _db.session.get(BackupRevision, result['backuprevisionid'])
data = revision.to_dict()
assert data['lastseenat'] is not None
assert data['lastseenat'].endswith('Z')
def test_the_panel_shows_verified_beside_captured(bk_app, bk_plugin):
with bk_app.app_context():
panels = bk_plugin.get_asset_panels()
listpanel = next(p for p in panels if p.get('render') == 'list')
labels = [m['label'] for m in listpanel['map']['meta']]
assert 'Captured' in labels and 'Verified' in labels