feat: add full snapshot downloads and safe manual restore
This commit is contained in:
@@ -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
@@ -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
@@ -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 = '';
|
||||
|
||||
Reference in New Issue
Block a user