Files
HealthLog/src/components/admin/offhost-backups-section.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

231 lines
9.0 KiB
TypeScript

"use client";
/**
* `<OffhostBackupsSection>` — per-account freshness of the off-host copies.
*
* The card above it lists the copies that live in THIS database. This one
* answers the different question: for each account, how long ago did this host
* put an encrypted copy in the operator's bucket, and is that inside what the
* nightly schedule promises. The two fail independently — a database whose
* weekly rows are current tells an operator nothing about a bucket that has
* been refusing the worker's signature since March.
*
* Every verdict is computed server-side from the ledger the worker writes. The
* page never lists the bucket: putting a credentialed listing call on the
* render path of an admin page would be a poor trade for a number the database
* already holds. It does read the bucket's lifecycle rule (one small call,
* cached for ten minutes on the server), because whether old copies expire at
* all is something only the bucket knows, and it states deletions of deleted
* or wiped accounts that the bucket has not carried out yet.
*/
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";
import { EmptyState } from "@/components/ui/empty-state";
import { ListRow } from "@/components/ui/list-row";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { Skeleton } from "@/components/ui/skeleton";
import { apiGet } from "@/lib/api/api-fetch";
import { cn } from "@/lib/utils";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { OffhostBackupFreshness } from "@/lib/jobs/offhost-backup-freshness";
import type { BackupsList, OffhostAccountRow } from "@/types/backups";
import { formatBytes } from "@/lib/format/bytes";
/**
* Status wash per verdict. `never` and `unknown` are deliberately neutral
* rather than alarming: a host that turned the off-host job on this afternoon
* has not failed at anything yet, and painting every row red on day one is how
* an operator learns to stop reading the colour.
*/
const FRESHNESS_STYLE: Record<OffhostBackupFreshness, string> = {
fresh: "border-success/30 bg-success/10 text-success",
due: "border-warning/30 bg-warning/10 text-warning",
stale: "border-destructive/30 bg-destructive/10 text-foreground",
never: "border-border bg-muted text-foreground",
unknown: "border-border bg-muted text-foreground",
};
/**
* `--destructive` as 12px badge text on its own wash measures 4.08:1 in the
* light theme and 3.99:1 in the dark one, and `--muted-foreground` on
* `bg-muted` 3.6:1 in the dark one, all under the AA floor. So the badge
* takes the shape the admin status rows already use: the token paints a small
* indicator and the wording stays in `text-foreground`. The word states the
* condition, so nothing hangs on the colour alone.
*/
const FRESHNESS_INDICATOR: Partial<Record<OffhostBackupFreshness, string>> = {
stale: "bg-destructive",
};
function freshnessLabel(
freshness: OffhostBackupFreshness,
t: ReturnType<typeof useTranslations>["t"],
): string {
// Literal keys, one per arm: a template-built key resolves at runtime and is
// invisible to the call-site coverage guard.
switch (freshness) {
case "fresh":
return t("admin.section.backups.offhost.stateFresh");
case "due":
return t("admin.section.backups.offhost.stateDue");
case "stale":
return t("admin.section.backups.offhost.stateStale");
case "never":
return t("admin.section.backups.offhost.stateNever");
case "unknown":
return t("admin.section.backups.offhost.stateUnknown");
}
}
function AccountRow({ row }: { row: OffhostAccountRow }) {
const { t } = useTranslations();
const fmt = useFormatters();
return (
<ListRow asChild className="bg-muted/30 border-border">
<li
data-slot="offhost-backup-row"
data-offhost-username={row.username}
data-offhost-freshness={row.freshness}
>
<div className="flex items-start justify-between gap-3">
<span className="min-w-0 flex-1 truncate font-medium">
{row.username}
</span>
<Badge
variant="outline"
className={cn("shrink-0", FRESHNESS_STYLE[row.freshness])}
>
{FRESHNESS_INDICATOR[row.freshness] ? (
<span
aria-hidden="true"
data-slot="offhost-freshness-indicator"
className={cn(
"size-2 shrink-0 rounded-full",
FRESHNESS_INDICATOR[row.freshness],
)}
/>
) : null}
{freshnessLabel(row.freshness, t)}
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{row.lastAttemptAt === null
? // No run has recorded this account. Saying "never" here would
// claim the bucket is empty when what is empty is the ledger,
// which is exactly what every account looks like between the
// upgrade that adds it and the first nightly run after that.
t("admin.section.backups.offhost.unknownDetail")
: row.lastSuccessAt === null || row.sizeBytes === null
? t("admin.section.backups.offhost.neverDetail", {
when: fmt.dateTime(row.lastAttemptAt),
})
: `${fmt.dateTime(row.lastSuccessAt)} · ${formatBytes(
row.sizeBytes,
fmt,
)} · ${t("admin.section.backups.offhost.age", {
hours: row.ageHours ?? 0,
})}`}
</p>
</li>
</ListRow>
);
}
export function OffhostBackupsSection() {
const { t } = useTranslations();
const { data, isLoading, isError, refetch } = useQuery({
queryKey: queryKeys.adminBackups(),
queryFn: async () => apiGet<BackupsList>("/api/admin/backups"),
});
const offhost = data?.offhost;
return (
<SettingsCard>
<SettingsCardHeader
icon={CloudUpload}
title={t("admin.section.backups.offhost.title")}
description={t("admin.section.backups.offhost.description")}
status={
offhost && offhost.configured ? (
<Badge variant="secondary" className="text-xs">
{offhost.rows.length}
</Badge>
) : null
}
/>
{isLoading ? (
<div className="space-y-2" data-slot="offhost-backup-loading">
<Skeleton className="h-14 w-full" />
<Skeleton className="h-14 w-full" />
</div>
) : isError ? (
<QueryErrorRow
message={t("admin.section.backups.offhost.loadError")}
onRetry={() => void refetch()}
/>
) : offhost && !offhost.configured ? (
<EmptyState
icon={<CloudUpload className="size-6" />}
title={t("admin.section.backups.offhost.notConfiguredTitle")}
description={t(
"admin.section.backups.offhost.notConfiguredDescription",
)}
/>
) : 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} />
))}
</ul>
<p className="text-muted-foreground text-xs">
{t("admin.section.backups.offhost.scheduleNote", {
hours: offhost.periodHours,
})}
</p>
<p
className="text-xs"
data-slot="offhost-lifecycle"
data-lifecycle-state={offhost.lifecycle.state}
>
{offhost.lifecycle.state === "configured"
? t("admin.section.backups.offhost.lifecycleConfigured", {
days: offhost.lifecycle.expirationDays ?? 0,
})
: offhost.lifecycle.state === "missing"
? t("admin.section.backups.offhost.lifecycleMissing")
: t("admin.section.backups.offhost.lifecycleUnknown")}
</p>
{offhost.pendingDeletions.count > 0 ? (
<p
role="status"
className="text-xs"
data-slot="offhost-pending-deletions"
>
{offhost.pendingDeletions.lastFailure
? t("admin.section.backups.offhost.pendingDeletionsFailing", {
count: offhost.pendingDeletions.count,
error: offhost.pendingDeletions.lastFailure,
})
: t("admin.section.backups.offhost.pendingDeletions", {
count: offhost.pendingDeletions.count,
})}
</p>
) : null}
</div>
) : null}
</SettingsCard>
);
}