Files
dsh-web/web/src/BackupPanel.tsx
T
m1saka 602917698a feat: add safe backup management and theme preferences
Add snapshot usage and protected cleanup, preserve unsaved profile edits, avoid holding SQL connections during snapshot deletion, and fix download filenames. Include regression tests and API documentation.
2026-10-17 00:36:21 +08:00

153 lines
11 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.
import { useState, type FormEvent, type ReactNode } from 'react';
import { Download, HardDriveDownload, RotateCcw, Trash2, Upload } from 'lucide-react';
import { api, instancePath, type Instance, type Backup, type Snapshot } from './api';
import { ResourceState, Empty, bytes, date, useAction, useResource } from './ui';
import { useConfirm } from './components/ConfirmDialog';
import { AlertBox } from './components/ui/alert';
import { DialogContent, DialogFooter, DialogRoot } from './components/ui/dialog';
import { buttonVariants } from './components/ui/button';
import { inputClass } from './components/ui/input';
import { cn } from './lib/utils';
export function BackupPanel({instance, refresh, onRestored}: {instance: Instance; refresh: () => Promise<void>; onRestored: () => Promise<void>}) {
const base = instancePath(instance.id);
const backups = useResource(() => api.request<Backup[]>(base + '/backups'), base);
const snapshots = useResource(() => api.request<Snapshot[]>(base + '/snapshots'), base);
const action = useAction();
const confirm = useConfirm();
const [password, setPassword] = useState('');
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
const [restoreTarget, setRestoreTarget] = useState<Backup>();
async function refreshStorage() {
await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]);
}
async function remove(item: Backup | Snapshot, snapshot: boolean) {
if (snapshot && (item as Snapshot).protected) return;
if (!await confirm.ask({
title: snapshot ? '删除完整 home 快照' : '删除配置备份', danger: true, confirmLabel: '永久删除',
body: `确认永久删除 ${item.id}?此操作不可撤销,不影响当前 home。${snapshot ? '删除后将无法使用此快照回滚。' : '请先下载需要保留的配置包;删除配置包不会清理完整快照。'}`,
})) return;
await action.run(async () => {
try { await api.request(base + (snapshot ? '/snapshots?id=' : '/backups?id=') + encodeURIComponent(item.id), 'DELETE'); }
finally { await refreshStorage(); }
}, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除');
}
function createBackup(e: FormEvent) {
e.preventDefault();
void action.run(async () => {
try { await api.request(base + '/backups', 'POST', {password}); await backups.refresh(); }
finally { setPassword(''); }
}, '加密备份已创建');
}
function importBackup(e: FormEvent) {
e.preventDefault();
if (!file) return;
void action.run(async () => {
try {
if (file.size > 44743364) throw new Error('备份包超过大小限制');
const form = new FormData();
form.set('file', file);
form.set('password', importPassword);
await api.request(base + '/backups', 'POST', form);
await backups.refresh();
} finally { setImportPassword(''); }
}, '备份已验证并导入;尚未覆盖当前配置');
}
async function restore(backup: Backup, originalPassword: string) {
if (!await confirm.ask({
title: '恢复配置备份', danger: true, confirmLabel: '确认恢复',
body: '确认覆盖当前配置?服务器将先保留当前 home 快照,随后按需停止实例并恢复原运行状态。',
})) return false;
return action.run(async () => {
try { await api.request(base + '/restore', 'POST', {backup_id: backup.id, password: originalPassword}); await onRestored(); }
finally { await snapshots.refresh(); }
}, '配置恢复及插件验证完成,已恢复原运行状态。');
}
return <section aria-label="备份与恢复" className="grid gap-5">
{confirm.element}
<AlertBox variant="info">操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。</AlertBox>
<div className="grid gap-5 lg:grid-cols-2">
<form onSubmit={createBackup} className="grid content-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
<h3 className="m-0 flex items-center gap-1.5 text-[14px] font-semibold"><HardDriveDownload className="size-4 text-primary" aria-hidden />创建加密备份</h3>
<label className="grid gap-1.5 text-[13px] font-medium">当前登录密码
<input className={inputClass} type="password" autoComplete="current-password" required value={password} onChange={e => setPassword(e.target.value)} /></label>
<div><button className={cn(buttonVariants({size: 'sm'}))} disabled={action.busy || !password}>创建加密备份</button></div>
</form>
<form onSubmit={importBackup} className="grid content-start gap-3 rounded-lg border border-border bg-muted/40 p-4">
<h3 className="m-0 flex items-center gap-1.5 text-[14px] font-semibold"><Upload className="size-4 text-primary" aria-hidden />导入外部备份</h3>
<label className="grid gap-1.5 text-[13px] font-medium">加密备份文件
<input type="file" accept=".dshbak" onChange={e => setFile(e.target.files?.[0])}
className="w-full cursor-pointer rounded-md border border-input bg-card px-3 py-2 text-sm file:mr-3 file:rounded file:border-0 file:bg-secondary file:px-2.5 file:py-1 file:text-xs file:text-secondary-foreground" /></label>
<label className="grid gap-1.5 text-[13px] font-medium">原备份密码(旧平台密钥包留空)
<input className={inputClass} type="password" autoComplete="off" value={importPassword} onChange={e => setImportPassword(e.target.value)} /></label>
<div><button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy || !file}>验证并导入</button></div>
</form>
</div>
{!restoreTarget && action.feedback}
<h3 className="m-0 text-sm font-semibold">配置备份(.dshbak)</h3>
<p className="m-0 text-xs text-muted-foreground">仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。</p>
<ResourceState resource={backups} />
{backups.data?.length === 0 && <Empty>暂无配置备份</Empty>}
<div className="grid gap-2">
{backups.data?.map(b => <div key={b.id} className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-border bg-card px-3.5 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 truncate font-mono text-xs">{b.id}</p>
<p className="m-0 mt-0.5 text-xs text-muted-foreground">{date(b.created_at)} · {bytes(b.size)}</p>
</div>
<div className="flex flex-wrap gap-1.5">
<a className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} href={'/api' + base + '/backups?download=' + encodeURIComponent(b.id)}><Download aria-hidden />下载</a>
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy} onClick={() => setRestoreTarget(b)}><RotateCcw aria-hidden />恢复</button>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy} onClick={() => void remove(b, false)}><Trash2 aria-hidden />删除配置包</button>
</div>
</div>)}
</div>
<section aria-label="完整 home 快照" className="grid gap-3 border-t border-border pt-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="m-0 text-sm font-semibold">完整 home 快照</h3>
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy || snapshots.loading || backups.loading} onClick={() => void refreshStorage()}>刷新备份与占用</button>
</div>
<AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。</AlertBox>
<ResourceState resource={snapshots} />
{snapshots.data && !snapshots.error && <p className="m-0 text-sm">快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}</p>}
{snapshots.data?.length === 0 && <Empty>暂无完整 home 快照</Empty>}
{snapshots.data?.map(s => <div key={s.id} className="flex flex-wrap items-center gap-3 rounded-lg border border-border bg-card px-3.5 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 break-all font-mono text-xs">{s.id}</p>
<p className="m-0 mt-1 text-xs text-muted-foreground">{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}</p>
{s.protected && <p className="m-0 mt-1 text-xs text-destructive">受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}</p>}
</div>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
</div>)}
</section>
{restoreTarget && <RestoreDialog backup={restoreTarget} busy={action.busy} feedback={action.feedback} onClose={() => setRestoreTarget(undefined)} onSubmit={async password => {
const ok = await restore(restoreTarget, password);
if (ok) setRestoreTarget(undefined);
}} />}
</section>;
}
function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise<void>}) {
const [password, setPassword] = useState('');
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent title={'恢复备份 · ' + backup.id}>
<form className="grid gap-4" onSubmit={e => { e.preventDefault(); void onSubmit(password); }}>
{feedback}
<AlertBox variant="info">创建于 {date(backup.created_at)} · {bytes(backup.size)}。输入创建该备份时使用的登录密码;旧平台密钥包可留空。</AlertBox>
<label className="grid gap-1.5 text-[13px] font-medium">原备份密码
<input className={inputClass} type="password" autoComplete="off" value={password} onChange={e => setPassword(e.target.value)} disabled={busy} autoFocus /></label>
<DialogFooter className="border-t-0 pt-0">
<button type="button" className={cn(buttonVariants({variant: 'outline'}))} disabled={busy} onClick={onClose}>取消</button>
<button className={cn(buttonVariants({variant: 'destructive'}))} disabled={busy}>{busy ? '正在恢复…' : '恢复此备份'}</button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>;
}