feat: add full snapshot downloads and safe manual restore

This commit is contained in:
2026-10-17 01:36:21 +08:00
parent c6615faa49
commit f77f3d77c0
12 changed files with 1318 additions and 7 deletions
+77 -1
View File
@@ -26,7 +26,7 @@ function editor() {
const child = nodes(render(), n => typeof n.type === 'function' && n.props.backup)[0];
return child.type(child.props);
}
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); refresh.mockClear(); onRestored.mockClear();});
beforeEach(() => {state.slots = []; state.cursor = 0; state.snapshots = []; state.refreshStorage.mockClear(); vi.restoreAllMocks(); vi.unstubAllGlobals(); refresh.mockClear(); onRestored.mockClear();});
it('retains restore password and shows failure inside the owning modal after confirmation', async () => {
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('restore denied'));
nodes(render(), n => n.type === 'button' && Array.isArray(n.props.children) && n.props.children.includes('恢复'))[0].props.onClick();
@@ -108,3 +108,79 @@ it('only a successful restore invokes the profile reload callback', async () =>
expect(onRestored).toHaveBeenCalledOnce();
expect(refresh).not.toHaveBeenCalled();
});
const snapshot = {id: 'pre-restore/a b', size: 1024, created_at: '', protected: false, restorable: true, restore_reason: '', source_image: 'image', source_version: '1.2.3'};
function snapshotEditor() {
const child = nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)[0];
return child.type(child.props);
}
const submitSnapshot = () => nodes(snapshotEditor(), n => n.type === 'form')[0].props.onSubmit({preventDefault: vi.fn()});
const snapshotPassword = () => nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'password')[0];
it('warns about plaintext keys before download, cancels safely, and navigates only after confirmation', async () => {
state.snapshots = [snapshot];
const assign = vi.fn();
vi.stubGlobal('window', {location: {assign}});
button('下载快照').props.onClick();
const confirmation = nodes(render(), n => n.type === ConfirmDialog)[0];
expect(confirmation.props.request.options.body).toContain('未加密');
expect(confirmation.props.request.options.body).toContain('密钥');
expect(confirmation.props.request.options.body).toContain('明文');
expect(assign).not.toHaveBeenCalled();
confirmation.props.onClose(false);
await new Promise(resolve => setTimeout(resolve, 0));
expect(assign).not.toHaveBeenCalled();
button('下载快照').props.onClick();
nodes(render(), n => n.type === ConfirmDialog)[0].props.onClose(true);
await new Promise(resolve => setTimeout(resolve, 0));
expect(assign).toHaveBeenCalledExactlyOnceWith('/api/instances/one/snapshots?download=pre-restore%2Fa%20b');
});
it('restores a known snapshot with the current password and reloads the profile only on success', async () => {
state.snapshots = [snapshot];
const request = vi.spyOn(api, 'request').mockResolvedValue({ok: true});
expect(JSON.stringify(render())).toContain('1.2.3');
button('恢复快照').props.onClick();
await submitSnapshot();
expect(request).not.toHaveBeenCalled();
expect(nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
const text = JSON.stringify(snapshotEditor());
for (const warning of ['覆盖完整 home', '失败回滚副本', '保留原始快照', '临时停止', '就绪验证', '不会自动降级']) expect(text).toContain(warning);
snapshotPassword().props.onChange({target: {value: 'current-password'}});
await submitSnapshot();
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: false});
expect(onRestored).toHaveBeenCalledOnce();
expect(refresh).toHaveBeenCalledOnce();
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0);
});
it.each(['source_image', 'source_version'])('requires unknown compatibility acknowledgement when %s is missing, clears password and refreshes after failure', async missing => {
state.snapshots = [{...snapshot, [missing]: undefined}];
const request = vi.spyOn(api, 'request').mockRejectedValue(new Error('snapshot restore denied'));
button('恢复快照').props.onClick();
snapshotPassword().props.onChange({target: {value: 'current-password'}});
await submitSnapshot();
expect(request).not.toHaveBeenCalled();
expect(nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '确认覆盖并恢复快照')[0].props.disabled).toBe(true);
nodes(snapshotEditor(), n => n.type === 'input' && n.props.type === 'checkbox')[0].props.onChange({target: {checked: true}});
await submitSnapshot();
expect(request).toHaveBeenCalledExactlyOnceWith('/instances/one/snapshots', 'POST', {id: snapshot.id, password: 'current-password', confirm: true, allow_unknown_version: true});
expect(snapshotPassword().props.value).toBe('');
expect(JSON.stringify(nodes(snapshotEditor(), n => n.type === DialogContent)[0])).toContain('snapshot restore denied');
expect(onRestored).not.toHaveBeenCalled();
expect(refresh).toHaveBeenCalledOnce();
expect(state.refreshStorage).toHaveBeenCalledTimes(2);
});
it.each([{protected: true, restorable: true}, {protected: false, restorable: false}])('disables protected or incompatible snapshot restoration (%j)', flags => {
state.snapshots = [{...snapshot, ...flags, restore_reason: 'incompatible home', protection_reason: 'recovery required'}];
expect(button('恢复快照').props.disabled).toBe(true);
expect(JSON.stringify(render())).toContain('incompatible home');
expect(button('下载快照').props.disabled).toBe(false);
});
it('cancels snapshot restoration without requests or profile reloads', () => {
state.snapshots = [snapshot];
const request = vi.spyOn(api, 'request');
button('恢复快照').props.onClick();
nodes(snapshotEditor(), n => n.type === 'button' && n.props.children === '取消')[0].props.onClick();
expect(nodes(render(), n => typeof n.type === 'function' && n.props.snapshot)).toHaveLength(0);
expect(request).not.toHaveBeenCalled();
expect(onRestored).not.toHaveBeenCalled();
});
+63 -3
View File
@@ -18,6 +18,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
const [password, setPassword] = useState('');
const [importPassword, setImportPassword] = useState(''), [file, setFile] = useState<File>();
const [restoreTarget, setRestoreTarget] = useState<Backup>();
const [snapshotTarget, setSnapshotTarget] = useState<Snapshot>();
async function refreshStorage() {
await Promise.all([backups.refresh(), snapshots.refresh(), refresh()]);
}
@@ -33,6 +34,23 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
}, snapshot ? '快照已删除,空间占用已刷新' : '配置备份已删除');
}
async function downloadSnapshot(snapshot: Snapshot) {
if (!await confirm.ask({
title: '下载完整 home 快照', danger: true, confirmLabel: '确认下载',
body: '下载的是未加密的 tar.gz 完整 home 归档,可能包含密钥、密码及其他明文敏感数据。请妥善保管,不要公开分享。',
})) return;
window.location.assign('/api' + base + '/snapshots?download=' + encodeURIComponent(snapshot.id));
}
async function restoreSnapshot(snapshot: Snapshot, password: string, allowUnknownVersion: boolean) {
if (snapshot.protected || !snapshot.restorable || !password || ((!snapshot.source_image || !snapshot.source_version) && !allowUnknownVersion)) return false;
return action.run(async () => {
try {
await api.request(base + '/snapshots', 'POST', {id: snapshot.id, password, confirm: true, allow_unknown_version: allowUnknownVersion});
await onRestored();
} finally { await refreshStorage(); }
}, '完整 home 快照恢复及就绪验证完成,已恢复原运行状态。');
}
function createBackup(e: FormEvent) {
e.preventDefault();
void action.run(async () => {
@@ -88,7 +106,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
</form>
</div>
{!restoreTarget && action.feedback}
{!restoreTarget && !snapshotTarget && 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} />
@@ -112,7 +130,7 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
<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>
<AlertBox variant="info">升级或恢复前保留的完整 home 副本,包含依赖及工作文件,会计入空间配额;不包含在上面的配置包大小中。可下载未加密的 tar.gz 归档或恢复完整 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>}
@@ -121,8 +139,14 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
<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>}
<p className="m-0 mt-1 text-xs text-muted-foreground">来源版本:{s.source_version || '未知'}{!s.source_image && ' · 来源镜像未知'}</p>
{s.restore_reason && <p className="m-0 mt-1 text-xs text-muted-foreground">{s.restore_reason}</p>}
</div>
<div className="flex flex-wrap gap-1.5">
<button className={cn(buttonVariants({variant: 'outline', size: 'sm'}))} disabled={action.busy} onClick={() => void downloadSnapshot(s)}><Download aria-hidden />下载快照</button>
<button className={cn(buttonVariants({variant: 'secondary', size: 'sm'}))} disabled={action.busy || s.protected || !s.restorable} onClick={() => setSnapshotTarget(s)}><RotateCcw aria-hidden />恢复快照</button>
<button className={cn(buttonVariants({variant: 'destructive', size: 'sm'}))} disabled={action.busy || s.protected} onClick={() => void remove(s, true)}><Trash2 aria-hidden />删除快照</button>
</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>
@@ -130,9 +154,45 @@ export function BackupPanel({instance, refresh, onRestored}: {instance: Instance
const ok = await restore(restoreTarget, password);
if (ok) setRestoreTarget(undefined);
}} />}
{snapshotTarget && <SnapshotRestoreDialog snapshot={snapshotTarget} busy={action.busy} feedback={action.feedback} onClose={() => setSnapshotTarget(undefined)} onSubmit={async (password, allowUnknownVersion) => {
const ok = await restoreSnapshot(snapshotTarget, password, allowUnknownVersion);
if (ok) setSnapshotTarget(undefined);
}} />}
</section>;
}
function SnapshotRestoreDialog({snapshot, busy, feedback, onClose, onSubmit}: {snapshot: Snapshot; busy: boolean; feedback: ReactNode; onClose: () => void; onSubmit: (password: string, allowUnknownVersion: boolean) => Promise<void>}) {
const [password, setPassword] = useState('');
const [allowUnknownVersion, setAllowUnknownVersion] = useState(false);
const unknownVersion = !snapshot.source_image || !snapshot.source_version;
const disabled = busy || !password || snapshot.protected || !snapshot.restorable || (unknownVersion && !allowUnknownVersion);
async function submit(e: FormEvent) {
e.preventDefault();
if (disabled) return;
try { await onSubmit(password, unknownVersion && allowUnknownVersion); }
finally { setPassword(''); }
}
return <DialogRoot open onOpenChange={open => { if (!open && !busy) onClose(); }}>
<DialogContent title={'恢复完整 home 快照 · ' + snapshot.id}>
<form className="grid gap-4" onSubmit={submit}>
{feedback}
<AlertBox variant="destructive">此操作将覆盖完整 home,包括配置、依赖及工作文件,未保存的编辑将丢失。服务器会先保留当前 home 作为失败回滚副本,并保留原始快照;按需临时停止实例,完成就绪验证后恢复原运行状态。不会自动降级或切换当前镜像。</AlertBox>
<p className="m-0 text-xs text-muted-foreground">来源版本:{snapshot.source_version || '未知'}</p>
{unknownVersion && <label className="flex items-start gap-2 text-[13px]">
<input type="checkbox" checked={allowUnknownVersion} onChange={e => setAllowUnknownVersion(e.target.checked)} disabled={busy} />
我理解来源镜像或版本未知,无法确认兼容性,仍要恢复。
</label>}
<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)} 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={disabled}>{busy ? '正在恢复…' : '确认覆盖并恢复快照'}</button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>;
}
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(); }}>
+1 -1
View File
@@ -13,7 +13,7 @@ export interface Entry { name: string; path?: string; is_dir: boolean; size: num
export interface Usage { used_bytes: number; quota_bytes: number }
export interface TextFile { content: string; version: string }
export interface Backup { id: string; created_at: string; size: number }
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string }
export interface Snapshot { id: string; created_at: string; size: number; protected: boolean; protection_reason: string; size_error?: string; restorable: boolean; restore_reason: string; source_version?: string; source_image?: string }
export interface Audit { id: string; action: string; target: string; detail: string; user_id: string; created_at: string }
export function createApi(fetcher: typeof fetch = fetch) {
let csrf = '';