mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
fix(backup): notice a backup run the process died under, and take its account last
A weekly or nightly backup job whose process died (a restart, a container out of memory) stayed active until its two- or four-hour expiry, and its retry walked the accounts in the same order: the account that killed it has the oldest copy, so it led again, and every account after it went without a copy for as long as that record stayed too large. Both passes now send with a five-minute pg-boss heartbeat, so a dead run fails within minutes and its retry picks it up. Each pass records when it started and finished every account (backup_pass_attempts, migration 0373); an account whose last attempt never finished goes behind all the others. The backups page shows when the run in progress started and names an account a run died under, for the weekly and the off-host card, and a heartbeat timeout reads as what it means.
This commit is contained in:
1 parent
d55e329c48
commit
f18e6ed34b
31 files changed
+734
-19
No files matched your search
@@ -24,6 +24,7 @@ import { I18nProvider } from "@/lib/i18n/context";
|
||||
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 },
|
||||
};
|
||||
@@ -126,6 +127,7 @@ describe("<OffhostBackupsSection>", () => {
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
activity: { runningSince: null, interrupted: [] },
|
||||
pendingDeletions: {
|
||||
count: 2,
|
||||
oldestRequestedAt: "2026-09-20T10:00:00.000Z",
|
||||
@@ -149,4 +151,26 @@ describe("<OffhostBackupsSection>", () => {
|
||||
expect(html).toContain("after 30 days");
|
||||
expect(html).not.toContain('data-slot="offhost-pending-deletions"');
|
||||
});
|
||||
|
||||
it("names an account a run died under, and says since when one runs", () => {
|
||||
const html = render({
|
||||
configured: true,
|
||||
periodHours: 24,
|
||||
rows: [],
|
||||
...QUIET,
|
||||
activity: {
|
||||
runningSince: "2026-10-04T02:30:00.000Z",
|
||||
interrupted: [
|
||||
{
|
||||
userId: "u1",
|
||||
username: "account-one",
|
||||
startedAt: "2026-10-03T02:31:00.000Z",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(html).toContain('data-slot="backup-pass-running"');
|
||||
expect(html).toContain('data-interrupted-username="account-one"');
|
||||
expect(html).toContain("never finished");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* What a backup pass is doing now, and which accounts a run died under.
|
||||
*
|
||||
* A pass whose process died (a restart, or a container out of memory) used
|
||||
* to leave nothing on this page: the job sat `active` until its expiry and
|
||||
* the last finished run still read fine. The run in progress and the
|
||||
* accounts whose last attempt started and never finished are both recorded
|
||||
* now (`backup-pass-attempts.ts`), so the page says so while it matters.
|
||||
*/
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
|
||||
import { useFormatters, useTranslations } from "@/lib/i18n/context";
|
||||
import type { BackupPassActivity } from "@/lib/jobs/backup-pass-attempts";
|
||||
|
||||
export function BackupPassActivityNotice({
|
||||
activity,
|
||||
}: {
|
||||
activity: BackupPassActivity;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const fmt = useFormatters();
|
||||
if (activity.runningSince === null && activity.interrupted.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2" data-slot="backup-pass-activity">
|
||||
{activity.runningSince !== null ? (
|
||||
<p
|
||||
role="status"
|
||||
className="text-muted-foreground text-xs"
|
||||
data-slot="backup-pass-running"
|
||||
>
|
||||
{t("admin.section.backups.activity.running", {
|
||||
when: fmt.dateTime(activity.runningSince),
|
||||
})}
|
||||
</p>
|
||||
) : null}
|
||||
{activity.interrupted.length > 0 ? (
|
||||
<div
|
||||
role="alert"
|
||||
data-slot="backup-pass-interrupted"
|
||||
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.activity.interruptedTitle")}
|
||||
</p>
|
||||
<ul className="text-xs">
|
||||
{activity.interrupted.map((attempt) => (
|
||||
<li
|
||||
key={attempt.userId}
|
||||
data-interrupted-username={attempt.username}
|
||||
>
|
||||
{t("admin.section.backups.activity.interruptedAccount", {
|
||||
username: attempt.username,
|
||||
when: fmt.dateTime(attempt.startedAt),
|
||||
})}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-muted-foreground mt-2 text-xs">
|
||||
{t("admin.section.backups.activity.interruptedRemedy")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { QueryErrorRow } from "@/components/ui/query-error-row";
|
||||
import { SettingsCardActions } from "@/components/settings/_card-actions";
|
||||
@@ -1149,6 +1150,9 @@ export function BackupsSection() {
|
||||
{data?.schedule ? (
|
||||
<ScheduleHealthNotice schedule={data.schedule} />
|
||||
) : null}
|
||||
{data?.scheduleActivity ? (
|
||||
<BackupPassActivityNotice activity={data.scheduleActivity} />
|
||||
) : null}
|
||||
|
||||
{/* Upload card — separate from the table so admins can ingest a
|
||||
backup file independently of any existing rows. The visible
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CloudUpload } from "lucide-react";
|
||||
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -182,6 +183,7 @@ export function OffhostBackupsSection() {
|
||||
/>
|
||||
) : offhost ? (
|
||||
<div className="space-y-2">
|
||||
<BackupPassActivityNotice activity={offhost.activity} />
|
||||
<ul className="space-y-2" data-slot="offhost-backup-rows">
|
||||
{offhost.rows.map((row) => (
|
||||
<AccountRow key={row.userId} row={row} />
|
||||
|
||||
Reference in new issue
Block a user