mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user