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

+4
View File
@@ -25,6 +25,7 @@ import { CoachFeedbackSection } from "@/components/admin/coach-feedback-section"
import { ApiTokenOverviewSection } from "@/components/admin/api-token-overview-section";
import { AppLogPreviewSection } from "@/components/admin/app-log-preview-section";
import { BackupsSection } from "@/components/admin/backups-section";
import { OffhostBackupsSection } from "@/components/admin/offhost-backups-section";
import { DangerZoneSection } from "@/components/admin/danger-zone-section";
import { EncryptionSection } from "@/components/admin/encryption-section";
import { GeneralSettingsSection } from "@/components/admin/general-settings-section";
@@ -144,9 +145,12 @@ export function AdminSectionRenderer({
</SectionFrame>
);
case "backups":
// Two cards, two independent failure modes: the copies held in this
// database, then the copies that reached the operator's bucket.
return (
<SectionFrame>
<BackupsSection />
<OffhostBackupsSection />
</SectionFrame>
);
case "encryption":