fix(reinstall): use OSTemplate.vmid (not template_vmid) and convert memory bytes->MB

- AttributeError: OSTemplate has no 'template_vmid' field; the actual column is 'vmid'.
- Use OSTemplate.get_vmid_for_node() to support cross-node replicated templates,
  falling back to source_node/vmid otherwise.
- VMInstance.memory is stored in bytes, but Proxmox config expects MB.
This commit is contained in:
markmorado
2026-04-29 23:48:48 +02:00
parent 5169bf80a3
commit 89d70654fc
45 changed files with 2946 additions and 741 deletions

View File

@@ -1,5 +1,7 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { apiClient } from '@/lib/api-client';
import { getTasksWebSocket } from '@/lib/websocket';
import type { VMInstance, Snapshot, VMConfig } from '@/types';
// ==================== Query Keys ====================
@@ -24,6 +26,26 @@ export function useVirtualMachines() {
});
}
/** Subscribes to WebSocket vm_status_update events and patches the VM list cache in real time. */
export function useVMStatusSync() {
const qc = useQueryClient();
useEffect(() => {
const ws = getTasksWebSocket();
ws.connect();
const unsub = ws.onType('vm_status_update', (data: unknown) => {
const msg = data as { vmid: number; server_id: number; status: string };
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === msg.server_id && vm.vmid === msg.vmid
? { ...vm, status: msg.status as VMInstance['status'] }
: vm
)
);
});
return () => unsub();
}, [qc]);
}
export function useAllResources() {
return useQuery({
queryKey: vmKeys.resourcesAll,
@@ -153,16 +175,55 @@ interface PowerActionResult {
export function usePowerAction(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }>({
return useMutation<PowerActionResult, Error, { action: string; force?: boolean }, { previousVMs: VMInstance[] | undefined }>({
mutationFn: ({ action, force }) => {
const params = new URLSearchParams({ node });
if (force) params.set('force', '1');
return apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/${action}?${params}`);
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
onMutate: async ({ action }) => {
await qc.cancelQueries({ queryKey: vmKeys.all });
const previousVMs = qc.getQueryData<VMInstance[]>(vmKeys.all);
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === serverId && vm.vmid === vmid
? { ...vm, status: expectedStatus as VMInstance['status'] }
: vm
)
);
return { previousVMs };
},
onError: (_err, _vars, context) => {
if (context?.previousVMs) {
qc.setQueryData(vmKeys.all, context.previousVMs);
}
},
onSuccess: (_data, { action }) => {
const expectedStatus = action === 'start' || action === 'restart' ? 'running' : 'stopped';
let attempts = 0;
const maxAttempts = 12; // 12 * 3s = 36s max
const poll = () => {
attempts++;
apiClient
.get<VMStatusResponse>(`/proxmox/api/${serverId}/${prefix}/${vmid}/status?node=${node}`)
.then((liveStatus) => {
qc.setQueryData<VMInstance[]>(vmKeys.all, (old) =>
old?.map((vm) =>
vm.server_id === serverId && vm.vmid === vmid
? { ...vm, status: liveStatus.status as VMInstance['status'] }
: vm
)
);
if (liveStatus.status !== expectedStatus && attempts < maxAttempts) {
setTimeout(poll, 3000);
}
})
.catch(() => {
if (attempts < maxAttempts) setTimeout(poll, 3000);
});
};
setTimeout(poll, 3000);
},
});
}
@@ -227,3 +288,118 @@ export function useUpdateConfig(serverId: number, vmid: number, type: string) {
},
});
}
// ==================== Clone / Reinstall ====================
interface CloneRequest {
new_name: string;
full?: boolean;
target_node?: string;
target_storage?: string;
description?: string;
}
export function useCloneInstance(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<{ status: string; new_vmid: number; upid: string }, Error, CloneRequest>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/clone?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.resourcesAll });
},
});
}
export function useReinstallInstance(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string; vmid: number; upid: string }, Error, void>({
mutationFn: () =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/reinstall?node=${node}`),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.all });
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
},
});
}
// ==================== Change Password ====================
export function useChangePassword(serverId: number, vmid: number, type: string, node: string) {
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<{ status: string }, Error, { username: string; password: string }>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/${prefix}/${vmid}/change-password?node=${node}`, body),
});
}
// ==================== Notes ====================
export function useUpdateNotes(serverId: number, vmid: number, type: string, node: string) {
const qc = useQueryClient();
const prefix = type === 'lxc' ? 'container' : 'vm';
return useMutation<{ status: string }, Error, { description: string }>({
mutationFn: (body) =>
apiClient.put(`/proxmox/api/${serverId}/${prefix}/${vmid}/notes?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.all });
},
});
}
// ==================== Execute Command (qemu-agent) ====================
interface ExecResult {
success?: boolean;
stdout?: string;
stderr?: string;
exit_code?: number;
error?: string;
}
export function useExecuteCommand(serverId: number, vmid: number) {
return useMutation<ExecResult, Error, { node: string; command: string; timeout?: number }>({
mutationFn: ({ node, command, timeout = 30 }) => {
const params = new URLSearchParams({ node, command, timeout: String(timeout) });
return apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/execute?${params}`);
},
});
}
// ==================== ISO mount/unmount (KVM only) ====================
export interface IsoItem {
volid: string;
storage: string;
size?: number;
format?: string;
name?: string;
}
export function useNodeIsos(serverId: number, node: string, enabled = true) {
return useQuery<{ isos: IsoItem[] }>({
queryKey: ['isos', serverId, node],
queryFn: () => apiClient.get(`/proxmox/api/${serverId}/node/${node}/isos`),
enabled: enabled && !!node,
});
}
export function useAttachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string }, Error, { volid: string; device?: string }>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/attach?node=${node}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
},
});
}
export function useDetachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient();
return useMutation<{ status: string }, Error, { device?: string }>({
mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/detach?node=${node}`, body || {}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
},
});
}

View File

@@ -7,12 +7,17 @@ export const taskKeys = {
bulk: (limit?: number) => ['bulk-tasks', limit] as const,
};
interface TaskItem {
export interface TaskItem {
id?: number;
upid?: string;
type: string;
kind?: 'bulk' | 'proxmox' | 'deploy';
status: string;
action?: string;
description?: string;
progress_percent?: number;
current_step?: string;
name?: string;
items_count?: number;
completed_count?: number;
failed_count?: number;
@@ -22,7 +27,10 @@ interface TaskItem {
node?: string;
server_id?: number;
server_name?: string;
vmid?: number;
vm_type?: string;
exitstatus?: string;
exit_status?: string;
starttime?: number;
endtime?: number;
user?: string;