"""Which backup chains have stopped running. Split from the route for the same reason retention.py is: the rule here is the interesting part and it should be testable without an auth layer in the way. THE RULE, and it is not the obvious one. Dedup means an unchanged configuration writes no revision, so `collectedat` moves only when something CHANGES. A machine whose settings have been stable for six months has a six-month-old newest revision and is entirely healthy. Staleness is therefore measured from `lastseenat` - when the config was last CONFIRMED current, recorded on every matching post including the no-op that writes nothing else. Keyed on the CHAIN (asset, kind, source PC), because that is the unit that fails independently. A machine with two part markers can have one still reporting while the other stopped, and a per-asset view would report the machine as fine. """ from datetime import datetime, timedelta from shopdb.api import db, Asset from ..models import BackupRevision DEFAULTSTALEDAYS = 3 def staledays(): """Configured threshold; 0 or negative disables the card entirely.""" from shopdb.api import Setting setting = Setting.query.filter_by(key='backups_staledays').first() if setting and (setting.value or '').strip(): try: return int(setting.value) except (TypeError, ValueError): pass return DEFAULTSTALEDAYS def latestperchain(): """Newest revision of every (asset, kind, source) chain.""" latest = {} for revision in (BackupRevision.query .order_by(BackupRevision.backuprevisionid.asc()).all()): latest[(revision.assetid, revision.backupkind, revision.sourcehostname)] = revision return latest def stalechains(days=None, limit=50): """Chains whose last confirmed check is older than the threshold. Deliberately silent about assets that have NEVER been backed up. Whether one SHOULD be is a question only the manifest can 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. That is the desired-versus-observed card, which needs the manifest resolver. """ days = staledays() if days is None else days if days <= 0: return [] now = datetime.utcnow() cutoff = now - timedelta(days=days) rows = [] for revision in latestperchain().values(): # NULL means never confirmed since the column existed, and it is NOT # substituted with collectedat. That substitution is what the first # version did, and on a real fleet it reported every machine whose # config had simply been stable for months as a stopped backup - 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; until then # this card says nothing about it, which is the truth. seen = revision.lastseenat if seen is None or seen >= cutoff: continue asset = db.session.get(Asset, revision.assetid) rows.append({ 'assetid': revision.assetid, 'assetnumber': asset.assetnumber if asset else str(revision.assetid), 'backupkind': revision.backupkind, 'sourcehostname': revision.sourcehostname, 'lastseenat': seen.isoformat() + 'Z', 'quietdays': (now - seen).days, }) rows.sort(key=lambda r: -r['quietdays']) return rows[:limit]