Files
kandev/apps/web/components/settings/system/backups-table.tsx
T
2026-08-27 14:00:48 +00:00

327 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Card, CardContent } from "@kandev/ui/card";
import { Button } from "@kandev/ui/button";
import { Badge } from "@kandev/ui/badge";
import { Spinner } from "@kandev/ui/spinner";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table";
import { IconDownload, IconTrash, IconArchive, IconRotateClockwise } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useIsAdmin } from "@/hooks/domains/auth/use-is-admin";
import { useBackups } from "@/hooks/domains/system/use-backups";
import { buildBackupDownloadUrl, createBackup, deleteBackup } from "@/lib/api/domains/system-api";
import { formatDateTime } from "@/lib/i18n/formats";
import { formatBytes } from "@/lib/utils/format-bytes";
import { JobProgressIndicator } from "./job-progress-indicator";
import { RestoreDialog } from "./restore-dialog";
import type { SnapshotInfo, SnapshotKind } from "@/lib/types/system";
import { SettingsCardHeader } from "@/components/settings/settings-card-header";
import {
SettingsErrorText,
SettingsFieldDescription,
} from "@/components/settings/settings-typography";
import { settingsActionClassName } from "@/components/settings/settings-control";
const BACKUP_CREATE_TIMEOUT_MS = 15_000;
const BACKUP_CREATE_POLL_MS = 250;
/**
* How many snapshots the backend keeps. Only referenced by the help text, but
* it travels as a `count` so the sentence inflects in every locale rather than
* hardcoding the English plural.
*/
const BACKUP_RETENTION_COUNT = 2;
const BACKUP_ROW_ACTION_CLASS_NAME =
"h-6 w-6 cursor-pointer p-0 [@media(pointer:coarse)]:h-11 [@media(pointer:coarse)]:w-11";
function formatTimestamp(iso: string): string {
if (!iso) return "-";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return formatDateTime(d);
}
/** `kind` is the wire enum; only its badge label is copy. */
const KIND_LABEL_KEYS: Record<SnapshotKind, string> = {
auto: "system:backupsKindAuto",
manual: "system:backupsKindManual",
};
function BackupRowActions({
row,
onRestore,
onDelete,
}: {
row: SnapshotInfo;
onRestore: (name: string) => void;
onDelete: (name: string) => void;
}) {
const { t } = useTranslation();
const downloadLabel = t("system:backupsDownloadLabel", { name: row.name });
const restoreLabel = t("system:backupsRestoreLabel", { name: row.name });
const deleteLabel = t("system:backupsDeleteLabel", { name: row.name });
const downloadTooltip = t("task:download");
const restoreTooltip = t("task:restore");
const deleteTooltip = t("task:delete");
return (
<div className="flex items-center justify-end gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
size="sm"
variant="ghost"
className={BACKUP_ROW_ACTION_CLASS_NAME}
data-testid="system-backups-download"
>
{/* These three are icon-only, so the aria-label is their only
accessible name. It names the snapshot it acts on. */}
<a href={buildBackupDownloadUrl(row.name)} download aria-label={downloadLabel}>
<IconDownload className="h-3.5 w-3.5" />
</a>
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">{downloadTooltip}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={BACKUP_ROW_ACTION_CLASS_NAME}
onClick={() => onRestore(row.name)}
aria-label={restoreLabel}
data-testid="system-backups-restore"
>
<IconRotateClockwise className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">{restoreTooltip}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className={`${BACKUP_ROW_ACTION_CLASS_NAME} text-destructive`}
onClick={() => onDelete(row.name)}
aria-label={deleteLabel}
data-testid="system-backups-delete"
>
<IconTrash className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent className="max-w-xs break-words">{deleteTooltip}</TooltipContent>
</Tooltip>
</div>
);
}
function BackupRow({
row,
isAdmin,
onRestore,
onDelete,
}: {
row: SnapshotInfo;
isAdmin: boolean;
onRestore: (name: string) => void;
onDelete: (name: string) => void;
}) {
const { t } = useTranslation();
return (
<TableRow data-testid="system-backups-row" data-name={row.name}>
{/* The snapshot filename is a value. */}
<TableCell className="font-mono text-xs break-all" data-testid="system-backups-name">
{row.name}
</TableCell>
<TableCell>
<Badge variant={row.kind === "manual" ? "default" : "secondary"} className="text-[10px]">
{KIND_LABEL_KEYS[row.kind] ? t(KIND_LABEL_KEYS[row.kind]) : row.kind}
</Badge>
</TableCell>
<TableCell className="text-xs text-right">{formatBytes(row.size_bytes)}</TableCell>
<TableCell className="text-xs">{formatTimestamp(row.mtime)}</TableCell>
{/* The whole cell goes, not just its contents: the Actions header is
conditional too, and an empty fifth cell would leave the table with
four headers and five cells: an unlabeled column for assistive
technology and dead width on a phone. */}
{isAdmin && (
<TableCell>
<BackupRowActions row={row} onRestore={onRestore} onDelete={onDelete} />
</TableCell>
)}
</TableRow>
);
}
function BackupsList({
items,
isAdmin,
onRestore,
onDelete,
}: {
items: SnapshotInfo[];
isAdmin: boolean;
onRestore: (name: string) => void;
onDelete: (name: string) => void;
}) {
const { t } = useTranslation();
return (
<Table data-testid="system-backups-table">
<TableHeader>
<TableRow>
<TableHead>{t("system:backupsColumnName")}</TableHead>
<TableHead>{t("system:backupsColumnKind")}</TableHead>
<TableHead className="text-right">{t("system:backupsColumnSize")}</TableHead>
<TableHead>{t("system:backupsColumnCreated")}</TableHead>
{isAdmin && (
<TableHead className="text-right">{t("system:backupsColumnActions")}</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<BackupRow
key={row.name}
row={row}
isAdmin={isAdmin}
onRestore={onRestore}
onDelete={onDelete}
/>
))}
</TableBody>
</Table>
);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForCreatedBackup(
reload: () => Promise<SnapshotInfo[]>,
previousNames: Set<string>,
t: TFunction,
): Promise<void> {
const deadline = Date.now() + BACKUP_CREATE_TIMEOUT_MS;
while (Date.now() < deadline) {
const items = await reload();
if (items.some((item) => item.kind === "manual" && !previousNames.has(item.name))) {
return;
}
await sleep(BACKUP_CREATE_POLL_MS);
}
// This message is caught and rendered into the card's error line, so it is
// display copy rather than a control-flow-only throw.
throw new Error(t("system:backupsCreateTimeout", { seconds: BACKUP_CREATE_TIMEOUT_MS / 1_000 }));
}
export function BackupsTable() {
const { t } = useTranslation();
const { backups, loaded, isLoading, reload } = useBackups();
// Creating, downloading, restoring, and deleting a snapshot are admin-only
// on the backend: a snapshot is a copy of the whole multi-user database.
// Members keep the read-only listing.
const isAdmin = useIsAdmin();
const [creating, setCreating] = useState(false);
const [restoreName, setRestoreName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const onCreate = async () => {
setCreating(true);
setError(null);
const previousNames = new Set(backups.map((backup) => backup.name));
try {
await createBackup();
await waitForCreatedBackup(reload, previousNames, t);
} catch (err) {
setError(err instanceof Error ? err.message : t("system:backupsCreateFailed"));
} finally {
setCreating(false);
}
};
const onDelete = async (name: string) => {
setError(null);
try {
await deleteBackup(name);
await reload();
} catch (err) {
setError(err instanceof Error ? err.message : t("system:backupsDeleteFailed"));
}
};
const items = backups;
return (
<Card data-testid="system-backups-card">
<SettingsCardHeader
title={
<span className="flex items-center gap-2">
<IconArchive className="h-4 w-4" /> {t("system:backupsTitle")}
</span>
}
actions={
isAdmin && (
<Button
size="sm"
disabled={creating}
onClick={() => void onCreate()}
className={settingsActionClassName("cursor-pointer")}
data-testid="system-backups-create"
>
{creating ? t("system:backupsCreating") : t("system:backupsCreate")}
</Button>
)
}
/>
<CardContent className="space-y-4">
<SettingsFieldDescription data-testid="system-backups-help">
{t("system:backupsHelp")}{" "}
{t("system:backupsRetention", { count: BACKUP_RETENTION_COUNT })}
</SettingsFieldDescription>
{!isAdmin && (
<SettingsFieldDescription data-testid="system-backups-admin-only">
{t("system:backupsAdminOnly")}
</SettingsFieldDescription>
)}
{error && <SettingsErrorText data-testid="system-backups-error">{error}</SettingsErrorText>}
{!loaded && isLoading && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> {t("system:backupsLoading")}
</div>
)}
{loaded && items.length === 0 && (
<p className="text-sm text-muted-foreground" data-testid="system-backups-empty">
{t("system:backupsEmpty")}
</p>
)}
{items.length > 0 && (
<BackupsList
items={items}
isAdmin={isAdmin}
onRestore={(n) => setRestoreName(n)}
onDelete={(n) => void onDelete(n)}
/>
)}
<div className="flex flex-col gap-1">
<JobProgressIndicator kind="backup-create" />
<JobProgressIndicator kind="restore" />
</div>
<RestoreDialog
open={restoreName !== null}
onOpenChange={(open) => {
if (!open) setRestoreName(null);
}}
name={restoreName ?? ""}
/>
</CardContent>
</Card>
);
}