fix(db-backup): read the off-host leg of the dump on its own

A run that kept its dump on the volume and failed the upload is the newest
good dump and the newest failure at the same instant. Compared with ">", the
card showed no failure and readiness said fresh, night after night, while no
dump left the host.

The health read now carries the off-host leg separately: when the newest dump
reached the bucket, and the newest one that did not. One pure reading of it
(databaseDumpProblems) feeds both the backups card and the readiness model.
A configured bucket that missed the newest dump is attention (offhost_failed)
with the upload's error, and failing once the newest dump there is older than
36 hours (offhost_stale) or none ever arrived (offhost_never). The card shows
the refused upload and when the bucket last got a dump. A failure at the
instant of the newest dump for another reason, retention throwing, is now
shown too.
This commit is contained in:
Marc-André Bombeck committed 2026-10-25 16:53:24 +01:00
1 parent 4887ed8071
commit 664b46d2c6
18 files changed
+427 -37

No files matched your search

@@ -28,6 +28,7 @@ const HEALTHY: DatabaseBackupHealth = {
offhost: true,
},
lastFailure: null,
offhost: { lastUploadAt: "2026-10-24T23:31:00.000Z", failure: null },
};
function render(
@@ -55,6 +56,36 @@ describe("<DatabaseDumpRow>", () => {
expect(html).not.toContain('data-slot="database-dump-no-lifecycle"');
});
it("says when the newest dump stayed on this server because its upload failed", () => {
const at = "2026-10-25T23:31:00.000Z";
const html = render({
...HEALTHY,
lastGood: { ...HEALTHY.lastGood!, at, offhost: false },
lastFailure: { at, error: "off-host upload failed: AccessDenied" },
offhost: {
lastUploadAt: "2026-10-22T23:31:00.000Z",
failure: { at, error: "off-host upload failed: AccessDenied" },
},
});
expect(html).toContain('data-slot="database-dump-offhost-failed"');
expect(html).toContain("did not reach the off-host bucket");
expect(html).toContain("AccessDenied");
expect(html).toContain("The newest dump in the bucket is from");
// Said once, as the upload it is, not also as a failed dump.
expect(html).not.toContain('data-slot="database-dump-failed"');
});
it("shows a failure at the instant of the newest dump, such as retention throwing", () => {
const at = "2026-10-25T23:31:00.000Z";
const html = render({
...HEALTHY,
lastGood: { ...HEALTHY.lastGood!, at },
lastFailure: { at, error: "EACCES: permission denied, unlink" },
});
expect(html).toContain('data-slot="database-dump-failed"');
expect(html).not.toContain('data-slot="database-dump-offhost-failed"');
});
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"');
@@ -6,7 +6,8 @@
* 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. A
* newest run failed (or, with a bucket, did not get its dump there), and
* whether this server can keep a dump at all. A
* directory inside the container (the image's own, without the volume) is
* writable and still keeps nothing past the next update, so "kept on this
* server" is only said of a directory that survives it.
@@ -16,6 +17,7 @@ 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 { databaseDumpProblems } from "@/lib/database-backup/dump-problems";
import type { DatabaseBackupHealth } from "@/lib/database-backup/health";
import type { LifecycleVerdict } from "@/lib/jobs/offhost-lifecycle";
@@ -41,9 +43,9 @@ export function DatabaseDumpRow({
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);
const problems = databaseDumpProblems(health);
const failure = problems.failure;
const offhostFailure = problems.offhost;
let setupProblem: string | null = null;
if (!health.enabled) {
@@ -115,7 +117,7 @@ export function DatabaseDumpRow({
</div>
</div>
{failedSinceGood && failure ? (
{failure ? (
<div
role="alert"
data-slot="database-dump-failed"
@@ -141,6 +143,39 @@ export function DatabaseDumpRow({
</div>
) : null}
{offhostFailure ? (
<div
role="alert"
data-slot="database-dump-offhost-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.offhostFailedTitle")}
</p>
<p className="text-xs">
{t("admin.section.backups.database.failedDescription", {
when: fmt.dateTime(offhostFailure.at),
reason: offhostFailure.error,
})}
</p>
<p className="text-xs">
{offhostFailure.lastUploadAt
? t("admin.section.backups.database.offhostLastUpload", {
when: fmt.dateTime(offhostFailure.lastUploadAt),
})
: t("admin.section.backups.database.offhostNeverUploaded")}
</p>
</div>
</div>
</div>
) : null}
{bucketLifecycle?.state === "missing" ? (
<SettingsInfoTile
icon={AlertTriangle}