feat(admin): say per account how fresh the off-host copy is

The nightly off-host job reports counts — uploaded, failed, total — and a
count cannot name the account it is failing for. A cohort where one object
has been refused every night for a month reads as "99 uploaded, 1 failed"
each time, which looks like weather rather than like an account with no
disaster-recovery copy at all.

The worker now writes a ledger row when an object lands (account, instant,
size), and Admin → Backups gains a second card that rates every account
against the nightly schedule: fresh inside one run, due when one run
produced nothing, stale when two did not, never when no object has ever
landed. On a host without BACKUP_S3_* the card says off-host backup is not
configured and stops there.

The verdicts are computed from the ledger, never from a bucket listing: the
worker's grant is deliberately PutObject + GetObject, and an admin page must
not put the operator's credentials on its render path to state a number the
database already holds.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 09:27:58 +02:00
1 parent 348d9e0739
commit 1c10bbbde8
23 files changed
+797 -10

No files matched your search

+36
View File
@@ -10,6 +10,7 @@
* keeps owning the HTTP contract, the component keeps owning the UI.
*/
import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status";
import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness";
export interface BackupRow {
id: string;
@@ -24,6 +25,35 @@ export interface BackupRow {
createdAt: string;
}
/** One account's newest object in the operator's off-host bucket. */
export interface OffhostAccountRow {
userId: string;
username: string;
/** ISO instant the newest object landed, or null when there is none. */
lastSuccessAt: string | null;
/** Size of that object in bytes, or null when there is none. */
sizeBytes: number | null;
/** Whole hours since it landed, or null when there is none. */
ageHours: number | null;
freshness: OffhostBackupFreshness;
}
/**
* The off-host picture, per account.
*
* Computed from the ledger the nightly worker writes, never from the bucket:
* the page must be able to answer "does this account have a recent copy
* off-host" on a host whose worker holds PutObject and nothing else.
*/
export interface OffhostBackupOverview {
/** Whether this host has the variables the nightly job needs. */
configured: boolean;
/** Hours between two scheduled runs — the number behind the verdicts. */
periodHours: number;
/** One row per account. Empty when off-host backup is not configured. */
rows: OffhostAccountRow[];
}
export interface BackupsList {
rows: BackupRow[];
/**
@@ -32,6 +62,12 @@ export interface BackupsList {
* made six weeks ago and one made on Sunday look identical in a table.
*/
schedule: BackupScheduleStatus;
/**
* The off-host leg. A weekly row in this table says the copy that lives in
* this database is current; it says nothing about whether anything reached
* the operator's bucket, and those two fail independently.
*/
offhost: OffhostBackupOverview;
/**
* Soft retention hint — the worker is configured for weekly backups
* (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and