Files
HealthLog/src/components/admin/backups-section.tsx
T
Marc-André Bombeck 77a76a9e33 refactor(backup): keep the upload flow owned by the backups section
The section that renders the recent-proof dialog is the one that hands
the proof to each gated flow, which the recent-proof callers test reads
from its source. The upload hook goes back there and the upload
component takes the flow it returns.
2026-10-29 20:41:58 +01:00

232 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
/**
* `<BackupsSection>` — admin view of the weekly `DataBackup` snapshots.
*
* Lists every snapshot row (one per user × backup-type) with size and age,
* plus a "Run backup now" CTA that enqueues the pg-boss `data-backup` job.
* The encrypted payload itself is never shipped to the browser; only the
* size in bytes is surfaced.
*/
import { useQuery } from "@tanstack/react-query";
import { BookOpen, Database, Loader2, PlayCircle } from "lucide-react";
import { BackupPassActivityNotice } from "@/components/admin/backup-pass-activity";
import { SettingsCardActions } from "@/components/settings/_card-actions";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { useRecentProof } from "@/components/settings/security-section/use-recent-proof";
import { SettingsCard } from "@/components/settings/settings-card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { QueryErrorRow } from "@/components/ui/query-error-row";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import type { BackupRow, BackupsList } from "@/types/backups";
import { RestoreJobStatus, visibleRestoreJobs } from "./backup-restore-status";
import { BackupRowsCards, BackupRowsTable } from "./backups/backup-rows";
import { DatabaseDumpRow } from "./backups/database-dump-row";
import { BackupUpload } from "./backups/backup-upload-card";
import { useBackupUpload } from "./backups/use-backup-upload";
import {
RestoreRefusalNotice,
RestoreSkipReport,
ScheduleHealthNotice,
} from "./backups/restore-notices";
import { useBackupDownload, useRunBackup } from "./backups/use-backup-actions";
import { useBackupRestores } from "./backups/use-backup-restores";
export function BackupsSection() {
const { t } = useTranslations();
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: queryKeys.adminBackups(),
queryFn: async () => {
return apiGet<BackupsList>("/api/admin/backups");
},
});
const runBackup = useRunBackup();
const rows: BackupRow[] = data?.rows ?? [];
// Download, upload and restore reach every account's data, so each asks for
// a fresh proof unless this session signed in or re-proved within minutes.
const recentProof = useRecentProof();
const { downloadingId, handleDownload } = useBackupDownload(recentProof);
const uploadFlow = useBackupUpload(recentProof);
const {
restoreJobs,
dismissedRestores,
dismissRestore,
restore,
restorePending,
skipped,
setSkipped,
refusedSections,
setRefusedSections,
} = useBackupRestores(recentProof);
const rowActions = {
downloadingId,
onDownload: (row: BackupRow) => void handleDownload(row),
restorePending,
onRestore: (
row: BackupRow,
options: { restoreInstanceSettings: boolean },
) => restore.mutate({ row, ...options }),
};
return (
<SettingsCard>
{recentProof.dialog}
{/* The snapshot count leads (numbers first) and the
"Run now" button follows. */}
<SettingsCardHeader
icon={Database}
title={t("admin.section.backups.title")}
titleAccessory={
data ? (
<Badge variant="secondary" className="text-xs">
{rows.length}
</Badge>
) : null
}
description={
<p>
{t("admin.section.backups.description")}{" "}
{/* External docs link — `noopener noreferrer` because this
leaves the admin shell. */}
<a
href="https://docs.healthlog.dev/guides/backups/"
target="_blank"
rel="noopener noreferrer"
className="text-primary inline-flex items-center gap-1 underline-offset-2 hover:underline"
>
<BookOpen className="h-3 w-3" aria-hidden="true" />
{t("admin.section.backups.docsLink")}
</a>
</p>
}
/>
{data?.database ? (
<DatabaseDumpRow
health={data.database}
bucketLifecycle={
data.offhost.configured ? data.offhost.lifecycle.database : null
}
/>
) : null}
{data?.schedule ? (
<ScheduleHealthNotice schedule={data.schedule} />
) : null}
{data?.scheduleActivity ? (
<BackupPassActivityNotice activity={data.scheduleActivity} />
) : null}
<BackupUpload flow={uploadFlow} />
{visibleRestoreJobs(restoreJobs, dismissedRestores).map((job) => (
<RestoreJobStatus
key={job.id}
job={job}
onDismiss={() => dismissRestore(job.id)}
/>
))}
<RestoreRefusalNotice
sections={refusedSections}
onDismiss={() => setRefusedSections([])}
/>
{skipped ? (
<RestoreSkipReport
report={skipped}
onDismiss={() => setSkipped(null)}
/>
) : null}
{isLoading ? (
<div className="flex items-center gap-2">
<Loader2 className="text-muted-foreground h-4 w-4 animate-spin motion-reduce:animate-none" />
<span className="text-muted-foreground text-sm">
{t("admin.section.backups.loading")}
</span>
</div>
) : isError ? (
<QueryErrorRow
inset
error={error}
message={t("admin.section.backups.loadError")}
onRetry={() => void refetch()}
/>
) : rows.length === 0 ? (
// Replace bare text with the EmptyState
// primitive. The header already exposes "Backup now" but a
// brand-new admin lands inside the card and benefits from a
// duplicate CTA right next to the explanation.
//
// Its own test id, because both buttons are in the tree while the list
// is empty and they do the same thing: one id on two elements forces
// every caller to pick one blind, and a third button would join the
// pick without anything failing.
<div>
<EmptyState
icon={<Database className="size-6" />}
title={t("admin.section.backups.emptyTitle")}
description={t("admin.section.backups.emptyDescription")}
action={
<Button
size="sm"
data-testid="backup-run-now-empty"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
}
/>
</div>
) : (
<div>
<BackupRowsTable rows={rows} {...rowActions} />
<BackupRowsCards rows={rows} {...rowActions} />
<p className="text-muted-foreground mt-2 text-xs">
{data!.preWipeCopyDays > 0
? t("admin.section.backups.retentionNote", {
days: data!.preWipeCopyDays,
})
: t("admin.section.backups.retentionNoteNoGrace")}
</p>
</div>
)}
<SettingsCardActions>
<Button
size="sm"
data-testid="backup-run-now"
disabled={runBackup.isPending}
onClick={() => runBackup.mutate()}
className="min-h-11"
>
{runBackup.isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
) : (
<PlayCircle className="h-3.5 w-3.5" />
)}
{t("admin.section.backups.runNow")}
</Button>
</SettingsCardActions>
</SettingsCard>
);
}