# How the backups card decides good vs stale The `backups` plugin's health verdict, as shown on the dashboard's **Backups stopped** card. Read it when the card looks wrong, or before changing the rule. Adding a new kind needs none of this - see [BACKUP-KINDS.md](BACKUP-KINDS.md). ## 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 card 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. It is a card of exceptions rather than a column of raw dates because a raw date 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 - and the date made the reader do arithmetic against a setting they would have to go and look up. ## What it does now `GET /api/backups/dashboard/stale` returns one row per chain that has gone quiet, worst first: | Field | Meaning | |-------|---------| | `assetid`, `assetnumber` | The asset the backup belongs to. | | `backupkind` | Which kind stopped. Chains fail independently, so the kind is part of the identity, not a summary. | | `sourcehostname` | The PC that was doing the collecting. | | `lastseenat` | When this chain was last CONFIRMED still current (ISO). | | `quietdays` | How long it has been quiet, which is what the card sorts on. | `lastseenat` is reported rather than the capture time because they answer different questions and one cannot stand for both: the first proves the check is running, the second only says what is stored. Collapsing them was what made a healthy machine look neglected. ## The chain is the unit, not the host A marker bay runs both an NTLARS backup and a part-marker backup. Reporting one verdict per host would let a healthy kind mask a dead one: the part-marker capture could stop for a month and the host would look fine on the strength of its NTLARS row. So the card is keyed on the CHAIN - asset, kind and source PC together - because that is the unit that fails independently. ## Silence is not a verdict A chain whose newest revision has never been confirmed - `lastseenat` NULL, which means a backfilled row or one written before the column existed - is not stale, it is UNMEASURABLE, and it is skipped rather than listed. Substituting `collectedat` for it looked right and was wrong: on a real fleet it reported every machine whose config had simply been stable for months as a stopped backup, so the whole board lit up and said the site was broken when it was fine. A chain becomes measurable the first time its PC posts, and the card says nothing about it until then. The card is equally silent about an asset that has NEVER been backed up. Whether one SHOULD be is a question the asset record cannot answer - most machines carry no NTLARS at all - and guessing would list a hundred healthy assets, which is the noise that makes a board worth ignoring. Known consequence: a device retired from a PC leaves its last revision behind, so that chain stays listed until the revision is pruned. That is the honest failure direction - it complains about a backup that genuinely is not running any more, rather than going quiet about one. 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 card. ## 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 appear, 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 the rule prefer it; the skip-the-unmeasurable contract stays as it is. ## Where the code lives - `plugins/backups/services/staleness.py` - `staledays()` (the shared threshold), `latestperchain()` (newest revision per chain), `stalechains()` (the verdict) - `plugins/backups/api/routes.py` - `dashboard_stale`, a thin route over that rule so it stays testable without an auth layer in the way - `plugins/backups/plugin.py` - the `backups-stale` dashboard widget declaration, including the `backups_staledays` setting Adding a kind is [BACKUP-KINDS.md](BACKUP-KINDS.md); backing up the ShopDB database itself is [BACKUP-RESTORE.md](BACKUP-RESTORE.md), a different subject.