mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
231 lines
9.0 KiB
TypeScript
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>
|
|
);
|
|
}
|