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