feat(backups): restore target node/storage selection + task progress tracking
All checks were successful
Backend Tests / pytest (Python 3.12) (push) Successful in 1m6s
Deploy to Production / Trigger production update (push) Successful in 8s

Add target node and storage overrides to single and bulk restore flows, and
track Proxmox task progress (parse_task_progress in the monitoring worker,
new ProxmoxTask progress column + migration, enriched task API responses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:26:01 +05:00
parent fbf6d174b6
commit 681aa2f303
10 changed files with 234 additions and 25 deletions

View File

@@ -30,6 +30,9 @@ import { formatBytes } from '@/lib/format';
import { toast } from 'sonner';
const ALL_NODES = '__all__';
// Sentinel for the target-storage selects: restore disks to the storage recorded
// in the backup config (i.e. omit the `storage` param), rather than a chosen one.
const FROM_BACKUP = '__from_backup__';
type BackupItem = {
volid: string;
@@ -126,6 +129,8 @@ export default function BackupsPage() {
// --- Restore dialog state ---
const [restoreTarget, setRestoreTarget] = useState<BackupItem | null>(null);
const [restoreTargetNode, setRestoreTargetNode] = useState('');
const [restoreTargetStorage, setRestoreTargetStorage] = useState(FROM_BACKUP);
const [restoreNewVmid, setRestoreNewVmid] = useState('');
const [restoreStart, setRestoreStart] = useState(false);
const [restoreUnique, setRestoreUnique] = useState(true);
@@ -137,14 +142,32 @@ export default function BackupsPage() {
const [selectedBackups, setSelectedBackups] = useState<Map<number, BackupItem>>(new Map());
const [bulkOpen, setBulkOpen] = useState(false);
const [bulkMode, setBulkMode] = useState<'in_place' | 'new_vmid'>('in_place');
const [bulkTargetNode, setBulkTargetNode] = useState('__source__');
const [bulkTargetStorage, setBulkTargetStorage] = useState(FROM_BACKUP);
const [bulkStart, setBulkStart] = useState(false);
const [bulkConfirm, setBulkConfirm] = useState(false);
const nodes = useMemo(() => (nodesData?.nodes || []), [nodesData]);
const storages = useMemo(() => {
const list = (storagesData?.storages || []) as { storage: string; type: string; content: string; used: number; total: number }[];
return list.filter(s => (s.content || '').split(',').includes('backup'));
}, [storagesData]);
const allStorages = useMemo(
() => (storagesData?.storages || []) as { storage: string; type: string; content: string; nodes?: string; used?: number; total?: number }[],
[storagesData],
);
const storages = useMemo(
() => allStorages.filter(s => (s.content || '').split(',').includes('backup')),
[allStorages],
);
// Storages that can hold restored guest disks on a given node.
// qemu => content 'images', lxc => 'rootdir', 'any' => either (bulk, mixed types).
const diskStoragesFor = (node: string, vmType: 'qemu' | 'lxc' | 'any') => {
const needed = vmType === 'lxc' ? ['rootdir'] : vmType === 'qemu' ? ['images'] : ['images', 'rootdir'];
return allStorages.filter(s => {
const contents = (s.content || '').split(',');
if (!needed.some(c => contents.includes(c))) return false;
// `nodes` empty/absent => storage is available on all nodes.
if (node && s.nodes) return s.nodes.split(',').includes(node);
return true;
});
};
const backups = (backupsData?.backups || []) as BackupItem[];
const jobs = (jobsData?.jobs || []) as BackupJob[];
@@ -209,6 +232,8 @@ export default function BackupsPage() {
const openRestore = (b: BackupItem) => {
setRestoreTarget(b);
setRestoreTargetNode(b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '');
setRestoreTargetStorage(FROM_BACKUP);
setRestoreNewVmid('');
setRestoreStart(false);
setRestoreUnique(true);
@@ -216,7 +241,7 @@ export default function BackupsPage() {
const submitRestore = () => {
if (!restoreTarget || !sid) return;
const node = restoreTarget.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node;
const node = restoreTargetNode;
if (!node) {
toast.error(t('backups.node_required'));
return;
@@ -233,7 +258,7 @@ export default function BackupsPage() {
node,
vmid: newVmidNum ?? restoreTarget.vmid,
archive: restoreTarget.volid,
storage: selectedStorage,
...(restoreTargetStorage !== FROM_BACKUP ? { storage: restoreTargetStorage } : {}),
vm_type: vmType,
new_vmid: newVmidNum,
start: restoreStart,
@@ -271,6 +296,8 @@ export default function BackupsPage() {
const openBulkRestore = () => {
if (selectedBackups.size === 0) return;
setBulkMode('in_place');
setBulkTargetNode('__source__');
setBulkTargetStorage(FROM_BACKUP);
setBulkStart(false);
setBulkConfirm(false);
setBulkOpen(true);
@@ -278,8 +305,9 @@ export default function BackupsPage() {
const submitBulkRestore = () => {
if (!sid || selectedBackups.size === 0) return;
const overrideNode = bulkTargetNode === '__source__' ? '' : bulkTargetNode;
const items = Array.from(selectedBackups.values()).map(b => ({
node: b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '',
node: overrideNode || b.node || (selectedNode === ALL_NODES ? '' : selectedNode) || nodes[0]?.node || '',
vmid: b.vmid,
archive: b.volid,
vm_type: detectVmType(b.volid),
@@ -289,7 +317,13 @@ export default function BackupsPage() {
return;
}
bulkRestore.mutate(
{ server_id: sid, storage: selectedStorage, mode: bulkMode, start: bulkStart, items },
{
server_id: sid,
...(bulkTargetStorage !== FROM_BACKUP ? { storage: bulkTargetStorage } : {}),
mode: bulkMode,
start: bulkStart,
items,
},
{
onSuccess: (res) => {
if (res.failed === 0) {
@@ -614,6 +648,28 @@ export default function BackupsPage() {
<div><span className="text-muted-foreground">{t('backups.type')}:</span> <Badge variant="outline" className="uppercase ml-1">{detectVmType(restoreTarget.volid)}</Badge></div>
<div><span className="text-muted-foreground">{t('backups.size')}:</span> {formatBytes(restoreTarget.size)}</div>
</div>
<div>
<Label>{t('backups.restore_target_node')}</Label>
<Select value={restoreTargetNode} onValueChange={v => { if (v !== null) setRestoreTargetNode(v); }}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
</div>
<div>
<Label>{t('backups.restore_target_storage')}</Label>
<Select value={restoreTargetStorage} onValueChange={v => { if (v !== null) setRestoreTargetStorage(v); }}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value={FROM_BACKUP}>{t('backups.storage_from_backup')}</SelectItem>
{diskStoragesFor(restoreTargetNode, detectVmType(restoreTarget.volid)).map(s => (
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.target_storage_hint')}</p>
</div>
<div>
<Label>{t('backups.new_vmid_optional')}</Label>
<Input
@@ -662,7 +718,7 @@ export default function BackupsPage() {
<DialogHeader>
<DialogTitle>{t('backups.bulk_restore_title')}</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
<div className="min-w-0 space-y-4 text-sm">
{/* Mode toggle */}
<div>
<Label>{t('backups.bulk_mode')}</Label>
@@ -692,7 +748,7 @@ export default function BackupsPage() {
{/* Selected items */}
<div>
<Label>{t('backups.selected_count', { count: selectedBackups.size })}</Label>
<div className="mt-1 max-h-44 overflow-y-auto rounded-md border divide-y">
<div className="mt-1 max-h-44 overflow-y-auto overflow-x-hidden rounded-md border divide-y">
{Array.from(selectedBackups.values()).map((b) => (
<div key={b.volid} className="flex items-center justify-between gap-2 px-2.5 py-1.5">
<span className="flex items-center gap-2 min-w-0">
@@ -706,6 +762,32 @@ export default function BackupsPage() {
</div>
</div>
<div>
<Label>{t('backups.bulk_target_node')}</Label>
<Select value={bulkTargetNode} onValueChange={v => setBulkTargetNode(v)}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value="__source__">— {t('backups.bulk_target_node_source')}</SelectItem>
{nodes.map(n => <SelectItem key={n.node} value={n.node}>{n.node}</SelectItem>)}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.bulk_target_node_hint')}</p>
</div>
<div>
<Label>{t('backups.restore_target_storage')}</Label>
<Select value={bulkTargetStorage} onValueChange={v => setBulkTargetStorage(v)}>
<SelectTrigger className="mt-1 w-full"><SelectValue /></SelectTrigger>
<SelectContent alignItemWithTrigger={false}>
<SelectItem value={FROM_BACKUP}>{t('backups.storage_from_backup')}</SelectItem>
{diskStoragesFor(bulkTargetNode === '__source__' ? '' : bulkTargetNode, 'any').map(s => (
<SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>
))}
</SelectContent>
</Select>
<p className="mt-1 text-xs text-muted-foreground">{t('backups.target_storage_hint')}</p>
</div>
<label className="flex items-center gap-2">
<input type="checkbox" className="h-4 w-4" checked={bulkStart} onChange={(e) => setBulkStart(e.target.checked)} />
<span>{t('backups.start_after_restore')}</span>

View File

@@ -60,7 +60,7 @@ export function useDeleteBackup() {
export function useRestoreBackup() {
return useMutation({
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage: string; vm_type: string; new_vmid?: number; start?: boolean }) =>
mutationFn: (data: { server_id: number; node: string; vmid: number; archive: string; storage?: string; vm_type: string; new_vmid?: number; start?: boolean; unique?: boolean }) =>
apiClient.post<{ success: boolean; upid: string }>('/proxmox/api/backups/restore', data),
});
}
@@ -77,7 +77,7 @@ export type BulkRestoreResult = {
export function useBulkRestoreBackup() {
const qc = useQueryClient();
return useMutation({
mutationFn: (data: { server_id: number; storage: string; mode: 'in_place' | 'new_vmid'; start: boolean; items: BulkRestoreItem[] }) =>
mutationFn: (data: { server_id: number; storage?: string; mode: 'in_place' | 'new_vmid'; start: boolean; items: BulkRestoreItem[] }) =>
apiClient.post<BulkRestoreResult>('/proxmox/api/backups/restore-bulk', data),
onSuccess: () => qc.invalidateQueries({ queryKey: ['backups'] }),
});

View File

@@ -380,6 +380,13 @@
"bulk_restore_done": "Restore started for {{count}} backup(s)",
"bulk_restore_partial": "Started: {{ok}}, failed: {{fail}}",
"node_required": "Could not determine node for the operation",
"restore_target_node": "Restore to node",
"restore_target_storage": "Target storage",
"storage_from_backup": "From backup configuration",
"target_storage_hint": "Where to place the restored disks. \"From backup configuration\" keeps the original storage — pick another when restoring to a node that lacks it.",
"bulk_target_node": "Target node (override)",
"bulk_target_node_source": "Use each backup's source node",
"bulk_target_node_hint": "Leave empty to restore each backup to its original node",
"invalid_vmid": "Invalid VMID",
"delete_title": "Delete backup",
"delete_confirm": "The backup file will be permanently deleted. Continue?",

View File

@@ -380,6 +380,13 @@
"bulk_restore_done": "Восстановление запущено для {{count}} бэкап(ов)",
"bulk_restore_partial": "Запущено: {{ok}}, с ошибкой: {{fail}}",
"node_required": "Не удалось определить узел для операции",
"restore_target_node": "Восстановить на узел",
"restore_target_storage": "Целевое хранилище",
"storage_from_backup": "Из конфигурации бэкапа",
"target_storage_hint": "Куда положить восстановленные диски. «Из конфигурации бэкапа» сохраняет исходное хранилище — выберите другое, если восстанавливаете на узел, где его нет.",
"bulk_target_node": "Целевой узел (переопределить)",
"bulk_target_node_source": "Исходный узел каждого бэкапа",
"bulk_target_node_hint": "Оставьте по умолчанию, чтобы каждый бэкап восстанавливался на свой исходный узел",
"invalid_vmid": "Некорректный VMID",
"delete_title": "Удалить бэкап",
"delete_confirm": "Файл бэкапа будет удалён без возможности восстановления. Продолжить?",