Files
HealthLog/src/components/admin/backup-pass-activity.tsx
T
Marc-André Bombeck f18e6ed34b 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.
2026-10-04 15:56:56 +02:00

79 lines
2.6 KiB
TypeScript

"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>
);
}