mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
Merge the release into the backup health work and read the real dump health
The readiness model now takes the database dump's state from getDatabaseBackupHealth, and the dump test reaches the off-host key id where it moved.
This commit is contained in:
commit
e88dcd2c53
99 files changed
+6106
-492
No files matched your search
@@ -26,7 +26,11 @@ import { OffhostBackupsSection } from "../offhost-backups-section";
|
||||
const QUIET = {
|
||||
activity: { runningSince: null, interrupted: [] },
|
||||
pendingDeletions: { count: 0, oldestRequestedAt: null, lastFailure: null },
|
||||
lifecycle: { state: "configured" as const, expirationDays: 30 },
|
||||
lifecycle: {
|
||||
state: "configured" as const,
|
||||
expirationDays: 30,
|
||||
database: { state: "configured" as const, expirationDays: 30 },
|
||||
},
|
||||
};
|
||||
|
||||
function render(data: BackupsList["offhost"] | null): string {
|
||||
@@ -133,7 +137,11 @@ describe("<OffhostBackupsSection>", () => {
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
lastFailure: "AccessDenied",
|
||||
},
|
||||
lifecycle: { state: "missing", expirationDays: null },
|
||||
lifecycle: {
|
||||
state: "missing",
|
||||
expirationDays: null,
|
||||
database: { state: "missing", expirationDays: null },
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-lifecycle-state="missing"');
|
||||
expect(html).toContain("no lifecycle rule");
|
||||
|
||||
@@ -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,
|
||||
@@ -98,7 +99,7 @@ export function BackupsSection() {
|
||||
{/* External docs link — `noopener noreferrer` because this
|
||||
leaves the admin shell. */}
|
||||
<a
|
||||
href="https://docs.healthlog.dev/admin/backups"
|
||||
href="https://docs.healthlog.dev/guides/backups/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
|
||||
@@ -110,6 +111,14 @@ export function BackupsSection() {
|
||||
}
|
||||
/>
|
||||
|
||||
{data?.database ? (
|
||||
<DatabaseDumpRow
|
||||
health={data.database}
|
||||
bucketLifecycle={
|
||||
data.offhost.configured ? data.offhost.lifecycle.database : null
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{data?.schedule ? (
|
||||
<ScheduleHealthNotice schedule={data.schedule} />
|
||||
) : null}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* The database row on the backups card says where the newest good dump is,
|
||||
* how long a deleted account stays in the dumps, and each thing that keeps a
|
||||
* dump from being taken or from ever ageing out of the bucket.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
|
||||
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
|
||||
|
||||
import { DatabaseDumpRow } from "../database-dump-row";
|
||||
|
||||
const HEALTHY: DatabaseBackupHealth = {
|
||||
enabled: true,
|
||||
directory: "/var/lib/healthlog/backups",
|
||||
keepNightly: 7,
|
||||
localDirWritable: true,
|
||||
offhostConfigured: true,
|
||||
lastGood: {
|
||||
at: "2026-10-24T23:31:00.000Z",
|
||||
fileName: "healthlog-20261024T233000Z-daily.dump",
|
||||
sizeBytes: 52_428_800,
|
||||
local: true,
|
||||
offhost: true,
|
||||
},
|
||||
lastFailure: null,
|
||||
};
|
||||
|
||||
function render(
|
||||
health: DatabaseBackupHealth,
|
||||
bucketLifecycle: LifecycleVerdict | null = {
|
||||
state: "configured",
|
||||
expirationDays: 30,
|
||||
},
|
||||
): string {
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<DatabaseDumpRow health={health} bucketLifecycle={bucketLifecycle} />
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("<DatabaseDumpRow>", () => {
|
||||
it("names the newest good dump, where it is, and how long dumps keep deleted accounts", () => {
|
||||
const html = render(HEALTHY);
|
||||
expect(html).toContain("kept on this server and off-host");
|
||||
expect(html).toContain("the newest 7 are kept on this server");
|
||||
expect(html).toContain("removed after 30 days");
|
||||
expect(html).not.toContain('data-slot="database-dump-failed"');
|
||||
expect(html).not.toContain('data-slot="database-dump-setup"');
|
||||
expect(html).not.toContain('data-slot="database-dump-no-lifecycle"');
|
||||
});
|
||||
|
||||
it("warns when no bucket rule ever removes the dumps", () => {
|
||||
const html = render(HEALTHY, { state: "missing", expirationDays: null });
|
||||
expect(html).toContain('data-slot="database-dump-no-lifecycle"');
|
||||
expect(html).toContain("never removed");
|
||||
});
|
||||
|
||||
it("shows a failure newer than the last good dump, and not an older one", () => {
|
||||
const failed = render({
|
||||
...HEALTHY,
|
||||
lastFailure: {
|
||||
at: "2026-10-25T23:31:00.000Z",
|
||||
error: "pg_dump exited 1: No space left on device",
|
||||
},
|
||||
});
|
||||
expect(failed).toContain('data-slot="database-dump-failed"');
|
||||
expect(failed).toContain("No space left on device");
|
||||
const recovered = render({
|
||||
...HEALTHY,
|
||||
lastFailure: { at: "2026-10-20T23:31:00.000Z", error: "old" },
|
||||
});
|
||||
expect(recovered).not.toContain('data-slot="database-dump-failed"');
|
||||
});
|
||||
|
||||
it("says when dumps go off-host only, or cannot be taken at all", () => {
|
||||
expect(render({ ...HEALTHY, localDirWritable: false }, null)).toContain(
|
||||
"dumps go off-host only",
|
||||
);
|
||||
expect(
|
||||
render(
|
||||
{ ...HEALTHY, localDirWritable: false, offhostConfigured: false },
|
||||
null,
|
||||
),
|
||||
).toContain("no database dump can be taken");
|
||||
expect(render({ ...HEALTHY, lastGood: null }, null)).toContain(
|
||||
"No database dump yet",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
"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";
|
||||
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
|
||||
|
||||
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,
|
||||
bucketLifecycle,
|
||||
}: {
|
||||
health: DatabaseBackupHealth;
|
||||
/** The bucket's rule for `database/`; null when nothing goes off-host. */
|
||||
bucketLifecycle: LifecycleVerdict | null;
|
||||
}) {
|
||||
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>
|
||||
{/* How long a deleted account stays in the dumps: the operator's
|
||||
side of the deletion promise (GDPR retention of backups). */}
|
||||
<p
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="database-dump-retention"
|
||||
>
|
||||
{t("admin.section.backups.database.retentionLocal", {
|
||||
nights: health.keepNightly,
|
||||
})}
|
||||
{bucketLifecycle?.state === "configured"
|
||||
? ` ${t("admin.section.backups.database.retentionOffhost", {
|
||||
days: bucketLifecycle.expirationDays ?? 0,
|
||||
})}`
|
||||
: null}
|
||||
</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}
|
||||
|
||||
{bucketLifecycle?.state === "missing" ? (
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="database-dump-no-lifecycle"
|
||||
>
|
||||
{t("admin.section.backups.database.retentionOffhostMissing")}
|
||||
</SettingsInfoTile>
|
||||
) : null}
|
||||
|
||||
{setupProblem ? (
|
||||
<SettingsInfoTile
|
||||
icon={AlertTriangle}
|
||||
tone="warning"
|
||||
role="alert"
|
||||
data-slot="database-dump-setup"
|
||||
>
|
||||
{setupProblem}
|
||||
</SettingsInfoTile>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user