Files
shopdb-flask/docs/BACKUP-KINDS.md
cproudlock 4bded210e4 geenforce: the backup hover names both dates, because they mean different things
"Checked 13 Aug 1:20 PM. Verified the backup taken 12 Aug 5:20 PM is still
current." Two facts, and one date could not carry both.

lastseenat moves on every successful confirmation and proves the check is still
running. collectedat moves only when the configuration actually CHANGED and says
what is stored. Showing lastseenat alone hid what is in the backup; showing
collectedat alone read as "last backup was a month ago" on a machine that is
perfectly healthy, because a stable config writes no new revision. The hover now
states the check and the capture separately and says outright that the second
being older is the normal case.

The stale wording gets the same treatment: it names the check that stopped AND
the newest copy that exists, which is the thing someone recovering from it
actually needs.

collectedat is exposed as backupcollectedat. Both stay tooltip-only - the badge
is still just the kind and a colour, so nothing here changes what a new backup
kind has to do to inherit it.
2026-08-13 13:35:05 -04:00

126 lines
6.2 KiB
Markdown

# Fleet config backups: adding a kind, and how health is judged
This is about the `backups` plugin - the per-PC CONFIG backups the collector
takes off shopfloor machines (NTLARS settings, part-marker configs). It is not
about backing up the ShopDB database itself; that is `docs/BACKUP-RESTORE.md`.
Two things this covers:
1. [Adding a new backup kind](#adding-a-new-backup-kind)
2. [How the Backup badge decides good vs stale](#how-the-backup-badge-decides-good-vs-stale)
---
## Adding a new backup kind
A kind is one class in `plugins/backups/services/registry.py`, added to
`REGISTRY` at the bottom of that file:
```python
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
```
Subclass `BackupKind` and override what applies. `NtlarsKind` is the fullest
example (parseable, renderable, has an info panel); `PartMarkerKind` is the
lean one.
| Member | What it is |
|--------|------------|
| `key` | Wire value. This is what lands in `backuprevisions.backupkind` and what the fleet table shows on the badge. Keep it short and lowercase. |
| `displayname` | Human label for the UI. |
| `storagebackend` | `'shopdb'` (bytes live in the DB, deduped on a semantic hash) or `'share'` (file lives on the SMB share, deduped on a raw byte hash). |
| `assettypes` | Which core asset types this kind can attach to; `['*']` for any. |
| `emptytext` | Text when an asset has no revisions of this kind. `None` HIDES the panel, which is the right default - a kind applies to an asset TYPE, but whether a given machine ever has that backup is per-machine. A part-marker panel on all 144 machines is noise. |
| `parse(raw)` | Opaque kinds return `None`; parseable kinds return a projection dict. |
| `formats()` / `render(...)` | Download formats, for `shopdb` kinds. |
| `resolveassetid(payload)` | Map a collector payload to the asset the backup belongs to. |
| `infopanel()` / `buildinfo(...)` | Optional at-a-glance card (ADR-010). Declared by the KIND, not hardcoded in the plugin, so a successor technology ships its own card by adding a class. |
| `sharedir(...)` | Conventional UNC directory for a `share` kind. Advisory - the authoritative path is whatever the collector reported, because the PC is what actually wrote the file. |
### What you do NOT have to touch
The GE-Enforce **Enforcement Reports** Backup column needs no change. It reads
`backuprevisions` generically: newest revision per host, whatever the kind, and
shows `backupkind` on the badge. A new kind inherits the badge, the colour and
the tooltip with no work.
Same for the staleness rule below - it is time-based and kind-agnostic.
---
## How the Backup badge decides good vs stale
### The trap this is built around
**A revision is only written when the config CHANGES.** Dedup compares against
the latest revision for the chain `(asset, kind, sourcehostname)`, so a machine
whose config has been stable for six months has a six-month-old newest revision
and is perfectly healthy. The question worth answering is not "when was the last
backup taken" - it is "is this still being checked".
So the timestamp the fleet table uses is `backuprevisions.lastseenat`: the last
time the collector CONFIRMED this config, whether or not anything changed. It
moves on every successful collection; the revision does not.
That was shown as a raw date at first, and it read as neglect. At the default
`backups_intervalhours` of **24**, the collector only attempts once a day, so a
day-old confirmation IS the healthy steady state. The date made a working system
look like a stalled one, and made the reader do arithmetic against a setting
they would have to go and look up.
### What it does now
`GET /api/geenforce/reports` returns, per host:
| Field | Meaning |
|-------|---------|
| `backupkind` | Which kind was most recently confirmed. `null` = no backup at all. |
| `backuplastseen` | When it was last CONFIRMED still current (ISO). Tooltip only. |
| `backupcollectedat` | When the config was last CAPTURED (ISO). Only moves on a real change. Tooltip only. |
| `backupok` | `true` good, `false` stale, `null` nothing to judge. |
| `backupstaleafterdays` | The threshold in force, so the UI can explain itself. |
Both dates are reported because they answer different questions and one cannot
stand for both. The hover reads *"ntlars: checked 13 Aug 1:20 PM. Verified the
backup taken 12 Aug 5:20 PM is still current."* - the first date proves the
check is running, the second says what is actually stored. Collapsing them was
what made a healthy machine look neglected.
`backupok` is deliberately **tri-state**. `null` means there is no revision for
that host, or the check is disabled - and it renders as no badge, never green.
"Never seen" must not read as healthy.
The threshold is the backups plugin's own `backups_staledays` setting (default
**3**), read through `plugins.backups.services.staleness.staledays()` rather
than re-derived, so there is ONE definition of stale. `0` disables the check.
The import is guarded, so a lean site build without the backups plugin returns
`null` instead of failing.
### Why time-based rather than per-kind
Every kind answers the same question the same way: something confirmed this
recently, or it did not. Making the rule per-kind would mean each new kind has
to define health before it can show a badge, for no gain. If a kind ever needs
its own window - a weekly backup that should not be judged on a 3-day rule -
add a threshold override on `BackupKind` and have `_backup_stale_cutoff` prefer
it; the tri-state contract stays as it is.
### Where the code lives
- `plugins/geenforce/api/routes.py` - `_attach_backup_state` (newest revision
per host), `_backup_stale_cutoff` (threshold), `_backup_ok` (verdict)
- `plugins/geenforce/frontend/views/EnforcementReports.vue` - `backupClass`
(green / red / none), `backupTitle` (the hover text)
- `plugins/backups/services/staleness.py` - the shared threshold, also behind
the dashboard's stale-backups card
Tests: `tests/test_plugins/test_geenforce_reporting.py`, the backup-verdict
block - recent-is-good, older-than-threshold-is-stale, and none-is-not-green.
---
## See also
- `docs/BACKUP-RESTORE.md` - backing up the ShopDB database itself
- `docs/geenforce-api-cutover.md` - the fleet reporting path these fields ride on