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; onRestored: () => Promise}) { const base = instancePath(instance.id); const backups = useResource(() => api.request(base + '/backups'), base); const snapshots = useResource(() => api.request(base + '/snapshots'), base); const action = useAction(); const confirm = useConfirm(); const [password, setPassword] = useState(''); const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState(); const [restoreTarget, setRestoreTarget] = useState(); 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
{confirm.element} 操作会按需停止实例并恢复原运行状态。备份使用创建时的登录密码加密;修改登录密码不改变旧包密码。恢复包含插件重建、重启验证和失败回滚。

创建加密备份

导入外部备份

{!restoreTarget && action.feedback}

配置备份(.dshbak)

仅包含指定配置与资源,不包含完整 home 或依赖;下方大小为加密配置包大小。

{backups.data?.length === 0 && 暂无配置备份}
{backups.data?.map(b =>

{b.id}

{date(b.created_at)} · {bytes(b.size)}

)}

完整 home 快照

升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。快照暂不支持网页下载,需要导出时请联系管理员。删除不可撤销,待恢复实例的快照受保护。 {snapshots.data && !snapshots.error &&

快照占用(文件字节数):{snapshots.data.some(s => s.size_error) ? '统计不完整,请查看各项错误' : bytes(snapshots.data.reduce((total, s) => total + s.size, 0))}

} {snapshots.data?.length === 0 && 暂无完整 home 快照} {snapshots.data?.map(s =>

{s.id}

{date(s.created_at)} · {s.size_error ? '大小统计失败:' + s.size_error : bytes(s.size)}

{s.protected &&

受保护:{s.protection_reason || '实例需要恢复,请联系管理员'}

}
)}
{restoreTarget && setRestoreTarget(undefined)} onSubmit={async password => { const ok = await restore(restoreTarget, password); if (ok) setRestoreTarget(undefined); }} />}
; } function RestoreDialog({backup, busy, feedback, onClose, onSubmit}: {backup: Backup; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string) => Promise}) { const [password, setPassword] = useState(''); return { if (!open && !busy) onClose(); }}>
{ e.preventDefault(); void onSubmit(password); }}> {feedback} 创建于 {date(backup.created_at)} · {bytes(backup.size)}。输入创建该备份时使用的登录密码;旧平台密钥包可留空。
; }