feat(admin): show the newest database dump on the backups page

GET /api/admin/backups now carries `database`: the newest good dump (when,
how big, kept on the server, off-host or both), the newest failed run,
whether this server can write its backup directory, and whether off-host
copies are configured. The backups card leads with one row built from it,
with a notice when the last run failed and when dumps can only go
off-host or cannot be taken at all. The route stays an unpublished admin
console route, so the OpenAPI document does not change.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 12:56:33 +01:00
1 parent e665f17f52
commit 3646502a86
14 files changed
+282 -10

No files matched your search

@@ -57,6 +57,24 @@ vi.mock("@/lib/jobs/offhost-backup", () => ({
}),
}));
const databaseHealth = {
enabled: true,
directory: "/var/lib/healthlog/backups",
localDirWritable: true,
offhostConfigured: true,
lastGood: {
at: "2026-09-21T23:31:40.000Z",
fileName: "healthlog-20260921T233000Z-daily.dump",
sizeBytes: 52_428_800,
local: true,
offhost: true,
},
lastFailure: null,
};
vi.mock("@/lib/database-backup/health", () => ({
getDatabaseBackupHealth: async () => databaseHealth,
}));
import { GET } from "../route";
import type { BackupsList } from "@/types/backups";
@@ -285,3 +303,10 @@ describe("GET /api/admin/backups — a run in progress and one a process died un
});
});
});
describe("GET /api/admin/backups — the database dump", () => {
it("carries the dump's health beside the account copies", async () => {
const body = await read();
expect(body.database).toEqual(databaseHealth);
});
});
+8 -1
View File
@@ -14,6 +14,10 @@
* stopped six weeks ago — every row it lists has a perfectly ordinary
* timestamp either way.
*
* The database row comes first in what an operator needs and last in this
* file: the nightly whole-database dump (`getDatabaseBackupHealth`), the
* one copy that rebuilds a lost server, read from its own ledger.
*
* And alongside both, the off-host leg: one row per account saying when this
* host last put that account's encrypted copy in the operator's bucket and how
* stale that is against the nightly schedule. It comes from the ledger the
@@ -31,6 +35,7 @@ import {
} from "@/lib/jobs/job-failures";
import { readBackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
import { summariseBackupSchedule } from "@/lib/jobs/backup-schedule-status";
import { getDatabaseBackupHealth } from "@/lib/database-backup/health";
import {
offhostBackupConfigured,
probeOffhostLifecycle,
@@ -190,7 +195,7 @@ export const GET = apiHandler(async () => {
readBackupPassActivity(prisma, DATA_BACKUP_QUEUE, weeklyRunningSince),
readBackupPassActivity(prisma, OFFHOST_BACKUP_QUEUE, offhostRunningSince),
]);
const [purgeCount, oldestPurge, lifecycle] = await Promise.all([
const [purgeCount, oldestPurge, lifecycle, database] = await Promise.all([
prisma.offhostPurgeRequest.count({ where: stillInBucket }),
prisma.offhostPurgeRequest.findFirst({
where: stillInBucket,
@@ -200,6 +205,7 @@ export const GET = apiHandler(async () => {
configured
? probeOffhostLifecycle()
: Promise.resolve({ state: "unknown" as const, expirationDays: null }),
getDatabaseBackupHealth(now),
]);
const payload: BackupsList = {
@@ -229,6 +235,7 @@ export const GET = apiHandler(async () => {
},
lifecycle,
},
database,
};
return apiSuccess(payload);
+2
View File
@@ -28,6 +28,7 @@ import type { BackupRow, BackupsList } from "@/types/backups";
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
import { DatabaseDumpRow } from "./backups/database-dump-row";
import { BackupUploadCard } from "./backups/backup-upload-card";
import {
RestoreRefusalNotice,
@@ -110,6 +111,7 @@ export function BackupsSection() {
}
/>
{data?.database ? <DatabaseDumpRow health={data.database} /> : null}
{data?.schedule ? (
<ScheduleHealthNotice schedule={data.schedule} />
) : null}
@@ -0,0 +1,114 @@
"use client";
/**
* The nightly whole-database dump, as one row on the backups card.
*
* The account copies below it restore one account on this server; the dump is
* the copy that rebuilds the server. So the row leads the card and says three
* things: when the newest good dump was taken and where it is, whether the
* newest run failed, and whether this server can keep a dump at all.
*/
import { AlertTriangle, HardDrive } from "lucide-react";
import { SettingsInfoTile } from "@/components/settings/_info-tile";
import { formatBytes } from "@/lib/format/bytes";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
function whereKept(
good: NonNullable<DatabaseBackupHealth["lastGood"]>,
t: ReturnType<typeof useTranslations>["t"],
) {
if (good.local && good.offhost) {
return t("admin.section.backups.database.whereBoth");
}
if (good.offhost) return t("admin.section.backups.database.whereOffhost");
return t("admin.section.backups.database.whereLocal");
}
export function DatabaseDumpRow({ health }: { health: DatabaseBackupHealth }) {
const { t, locale } = useTranslations();
const fmt = useFormatters();
const good = health.lastGood;
const failure = health.lastFailure;
const failedSinceGood =
failure !== null && (good === null || failure.at > good.at);
let setupProblem: string | null = null;
if (!health.enabled) {
setupProblem = t("admin.section.backups.database.disabled");
} else if (!health.localDirWritable) {
setupProblem = health.offhostConfigured
? t("admin.section.backups.database.noDirectoryOffhost", {
dir: health.directory,
})
: t("admin.section.backups.database.noDirectory", {
dir: health.directory,
});
}
return (
<div className="space-y-2" data-slot="database-dump">
<div className="flex items-start gap-2">
<HardDrive
className="text-muted-foreground mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="text-sm font-medium">
{t("admin.section.backups.database.title")}
</p>
<p
className="text-muted-foreground text-xs"
data-slot="database-dump-last-good"
>
{good
? t("admin.section.backups.database.lastGood", {
when: fmt.dateTime(good.at),
size: formatBytes(good.sizeBytes ?? 0, locale),
where: whereKept(good, t),
})
: t("admin.section.backups.database.none")}
</p>
</div>
</div>
{failedSinceGood && failure ? (
<div
role="alert"
data-slot="database-dump-failed"
className="border-destructive/40 bg-destructive/10 rounded-md border px-3 py-2 text-sm"
>
<div className="flex items-start gap-2">
<AlertTriangle
className="mt-0.5 h-4 w-4 shrink-0"
aria-hidden="true"
/>
<div>
<p className="font-medium">
{t("admin.section.backups.database.failedTitle")}
</p>
<p className="text-xs">
{t("admin.section.backups.database.failedDescription", {
when: fmt.dateTime(failure.at),
reason: failure.error,
})}
</p>
</div>
</div>
</div>
) : null}
{setupProblem ? (
<SettingsInfoTile
icon={AlertTriangle}
tone="warning"
role="alert"
data-slot="database-dump-setup"
>
{setupProblem}
</SettingsInfoTile>
) : null}
</div>
);
}
+7
View File
@@ -9,6 +9,7 @@
* both sides a route-handler-independent shared type home; the route
* keeps owning the HTTP contract, the component keeps owning the UI.
*/
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
import type { BackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
import type { BackupScheduleStatus } from "@/lib/jobs/backup-schedule-status";
import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness";
@@ -101,6 +102,12 @@ export interface BackupsList {
* the operator's bucket, and those two fail independently.
*/
offhost: OffhostBackupOverview;
/**
* The nightly whole-database dump, the copy that rebuilds a lost server:
* the newest good dump (kept here, off-host, or both), the newest failed
* run, and whether this server can keep dumps at all.
*/
database: DatabaseBackupHealth;
/**
* Soft retention hint — the worker is configured for weekly backups
* (see `DATA_BACKUP_CRON` in `src/lib/jobs/reminder-worker.ts`), and