feat(instances): ISO eject/mount with boot-order switch and hybrid reboot
Some checks failed
Deploy to Production / Trigger production update (push) Has been cancelled
Backend Tests / pytest (Python 3.12) (push) Has been cancelled

- Images: show real ISO files already on the node storage (incl. those
  present before the node joined the panel) via existing node/isos endpoint
- ISO dialog: rename "detach" to "Eject", show current image inside the
  select (drop overflowing side text), add "boot from disk" checkbox
- Backend: on eject, put disk first in boot order; on mount, optional
  "boot from this ISO" puts CD-ROM first
- Reboot to apply the new boot order via hybrid restart: graceful ACPI
  shutdown with forceStop fallback, then start (fixes guest-ping timeout
  on freshly installed OS without QEMU Guest Agent)
- Update iso_next_steps hint to reflect the automated flow
This commit is contained in:
markmorado
2026-07-24 23:00:32 +05:00
parent aa100b49c1
commit b7c3a6ed35
8 changed files with 311 additions and 37 deletions

View File

@@ -3649,10 +3649,18 @@ class NotesRequest(BaseModel):
class IsoAttachRequest(BaseModel): class IsoAttachRequest(BaseModel):
volid: str volid: str
device: str = "ide2" device: str = "ide2"
# Поставить этот ISO первым в порядке загрузки (грузиться с образа: live-CD и т.п.).
boot_from_iso: bool = False
# Перезапустить ВМ, чтобы новый порядок загрузки вступил в силу (только если запущена).
reboot_after: bool = True
class IsoDetachRequest(BaseModel): class IsoDetachRequest(BaseModel):
device: str = "ide2" device: str = "ide2"
# После извлечения ISO — поставить диск первым в порядке загрузки (грузиться с ОС).
boot_from_disk: bool = True
# Перезапустить ВМ, чтобы новый порядок загрузки вступил в силу (только если запущена).
reboot_after: bool = True
class ExecuteCommandRequest(BaseModel): class ExecuteCommandRequest(BaseModel):
@@ -3750,16 +3758,29 @@ def attach_iso_endpoint(
require_vm_access(db, current_user, server_id, vmid) require_vm_access(db, current_user, server_id, vmid)
server = _resolve_server(db, server_id) server = _resolve_server(db, server_id)
client = _get_client_or_503(server) client = _get_client_or_503(server)
ok = client.attach_iso(node, vmid, body.volid, body.device or 'ide2') device = body.device or 'ide2'
ok = client.attach_iso(node, vmid, body.volid, device)
if not ok: if not ok:
raise HTTPException(status_code=500, detail="Failed to attach ISO") raise HTTPException(status_code=500, detail="Failed to attach ISO")
boot_iso_set = False
rebooted = False
if body.boot_from_iso:
boot_iso_set = client.set_boot_iso_first(node, vmid, device)
# Новый порядок загрузки применяется только при power-cycle QEMU.
# Перезапускаем лишь запущенную ВМ.
if boot_iso_set and body.reboot_after:
status = client.get_vm_status(node, vmid) or {}
if status.get('status') == 'running':
rebooted = client.hybrid_restart_vm(node, vmid)
LoggingService.log_proxmox_action( LoggingService.log_proxmox_action(
db=db, action='attach-iso', resource_type='vm', resource_id=vmid, db=db, action='attach-iso', resource_type='vm', resource_id=vmid,
username=current_user.username, server_id=server_id, username=current_user.username, server_id=server_id,
server_name=server.name, node_name=node, server_name=server.name, node_name=node,
details={'volid': body.volid, 'device': body.device}, success=True, details={'volid': body.volid, 'device': device,
'boot_from_iso': boot_iso_set, 'rebooted': rebooted},
success=True,
) )
return {"status": "success"} return {"status": "success", "boot_from_iso": boot_iso_set, "rebooted": rebooted}
@router.post("/api/{server_id}/vm/{vmid}/iso/detach") @router.post("/api/{server_id}/vm/{vmid}/iso/detach")
@@ -3775,10 +3796,28 @@ def detach_iso_endpoint(
require_vm_access(db, current_user, server_id, vmid) require_vm_access(db, current_user, server_id, vmid)
server = _resolve_server(db, server_id) server = _resolve_server(db, server_id)
client = _get_client_or_503(server) client = _get_client_or_503(server)
ok = client.detach_iso(node, vmid, body.device or 'ide2') device = body.device or 'ide2'
ok = client.detach_iso(node, vmid, device)
if not ok: if not ok:
raise HTTPException(status_code=500, detail="Failed to detach ISO") raise HTTPException(status_code=500, detail="Failed to detach ISO")
return {"status": "success"} boot_disk_set = False
rebooted = False
if body.boot_from_disk:
boot_disk_set = client.set_boot_disk_first(node, vmid, exclude_device=device)
# Новый порядок загрузки применяется только при power-cycle QEMU (гостевой
# reboot конфиг не перечитывает). Перезапускаем лишь запущенную ВМ.
if boot_disk_set and body.reboot_after:
status = client.get_vm_status(node, vmid) or {}
if status.get('status') == 'running':
rebooted = client.hybrid_restart_vm(node, vmid)
LoggingService.log_proxmox_action(
db=db, action='detach-iso', resource_type='vm', resource_id=vmid,
username=current_user.username, server_id=server_id,
server_name=server.name, node_name=node,
details={'device': device, 'boot_from_disk': boot_disk_set, 'rebooted': rebooted},
success=True,
)
return {"status": "success", "boot_from_disk": boot_disk_set, "rebooted": rebooted}
# ══════════════════════════════════════════════════════════════════════════ # ══════════════════════════════════════════════════════════════════════════

View File

@@ -1,4 +1,5 @@
from typing import List, Dict, Optional, Union, Any from typing import List, Dict, Optional, Union, Any
import re
import time import time
import urllib3 import urllib3
from loguru import logger from loguru import logger
@@ -40,6 +41,46 @@ class VmMixin:
logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}") logger.error(f"Ошибка перезапуска VM {vmid} на {node}: {e}")
return None return None
def _wait_vm_stopped(self, node: str, vmid: int, timeout: int) -> bool:
"""Опрашивать статус ВМ, пока не 'stopped' или не выйдет timeout секунд."""
deadline = time.time() + timeout
while time.time() < deadline:
st = self.get_vm_status(node, vmid) or {}
if st.get('status') == 'stopped':
return True
time.sleep(1)
return False
def hybrid_restart_vm(self, node: str, vmid: int,
graceful_timeout: int = 20) -> bool:
"""Перезапуск «мягко, при таймауте — жёстко», затем start.
status/shutdown с forceStop=1 сначала шлёт ACPI-выключение, а если гость
не ответил за graceful_timeout секунд — Proxmox сам убивает процесс QEMU.
Это покрывает и живую ОС (корректное выключение), и свежий установщик без
ACPI/QEMU Guest Agent (где обычный reboot виснет на guest-ping). Нужен для
применения нового boot order. Возвращает True, если ВМ снова запущена.
"""
if not self.proxmox:
return False
try:
self.proxmox.nodes(node).qemu(vmid).status.shutdown.post(
timeout=graceful_timeout, forceStop=1)
# Ждём с запасом сверх graceful_timeout (форс-стоп внутри shutdown).
if not self._wait_vm_stopped(node, vmid, graceful_timeout + 20):
# Подстраховка: shutdown завис — бьём по процессу напрямую.
logger.warning(f"VM {vmid}: shutdown не завершился, принудительный stop")
self.proxmox.nodes(node).qemu(vmid).status.stop.post()
if not self._wait_vm_stopped(node, vmid, 15):
logger.error(f"VM {vmid} не остановилась — старт отменён")
return False
upid = self.proxmox.nodes(node).qemu(vmid).status.start.post()
logger.info(f"VM {vmid} на {node}: гибридный перезапуск выполнен, UPID: {upid}")
return isinstance(upid, str)
except Exception as e:
logger.error(f"Ошибка гибридного перезапуска VM {vmid} на {node}: {e}")
return False
def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]: def shutdown_vm(self, node: str, vmid: int, timeout: int = 60) -> Optional[str]:
"""Корректно выключить VM (ACPI). Возвращает UPID задачи.""" """Корректно выключить VM (ACPI). Возвращает UPID задачи."""
if not self.proxmox: if not self.proxmox:
@@ -204,6 +245,81 @@ class VmMixin:
logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}") logger.error(f"Ошибка отключения ISO у VM {vmid}: {e}")
return False return False
def _ordered_boot_disks(self, cfg: Dict,
exclude_device: Optional[str] = None) -> List[str]:
"""Загрузочные диски ВМ (не CD-ROM, не пустые) в порядке текущего boot.
Порядок между дисками берём из текущего boot=order=...; недостающие
добавляем в конец. exclude_device исключается принудительно. efidisk/
tpmstate не проходят regex дисковых устройств, поэтому не попадают.
"""
disk_re = re.compile(r'^(?:scsi|virtio|sata|ide)\d+$')
disks: List[str] = []
for key, val in cfg.items():
if not disk_re.match(key):
continue
if exclude_device and key == exclude_device:
continue
if not isinstance(val, str) or not val:
continue
if 'media=cdrom' in val or val.startswith('none'):
continue
disks.append(key)
ordered: List[str] = []
m = re.search(r'order=([^,]+)', str(cfg.get('boot') or ''))
if m:
for dev in m.group(1).split(';'):
if dev in disks and dev not in ordered:
ordered.append(dev)
for dev in disks:
if dev not in ordered:
ordered.append(dev)
return ordered
def set_boot_disk_first(self, node: str, vmid: int,
exclude_device: Optional[str] = None) -> bool:
"""Поставить дисковые устройства первыми в порядке загрузки, исключив CD-ROM.
Используется после установки ОС: извлекли ISO — грузимся с диска.
exclude_device (например отключаемый ide2) исключается из рассмотрения.
"""
if not self.proxmox:
return False
try:
cfg = self.proxmox.nodes(node).qemu(vmid).config.get() or {}
ordered = self._ordered_boot_disks(cfg, exclude_device=exclude_device)
if not ordered:
logger.warning(f"VM {vmid}: не найден загрузочный диск для boot order")
return False
self.proxmox.nodes(node).qemu(vmid).config.put(boot=f"order={';'.join(ordered)}")
logger.info(f"VM {vmid} на {node}: порядок загрузки → диск ({';'.join(ordered)})")
return True
except Exception as e:
logger.error(f"Ошибка установки boot order (диск) у VM {vmid}: {e}")
return False
def set_boot_iso_first(self, node: str, vmid: int,
iso_device: str = 'ide2') -> bool:
"""Поставить CD-ROM (ISO) первым в порядке загрузки, диски — следом.
Используется при монтировании ISO на работающую ВМ, когда нужно
загрузиться именно с образа (live-CD, восстановление, переустановка).
iso_device — привод с ISO (ide2 по умолчанию).
"""
if not self.proxmox:
return False
try:
cfg = self.proxmox.nodes(node).qemu(vmid).config.get() or {}
disks = self._ordered_boot_disks(cfg) # CD-ROM сюда не попадёт
order = [iso_device] + [d for d in disks if d != iso_device]
self.proxmox.nodes(node).qemu(vmid).config.put(boot=f"order={';'.join(order)}")
logger.info(f"VM {vmid} на {node}: порядок загрузки → ISO ({';'.join(order)})")
return True
except Exception as e:
logger.error(f"Ошибка установки boot order (ISO) у VM {vmid}: {e}")
return False
def force_stop_vm(self, node: str, vmid: int) -> bool: def force_stop_vm(self, node: str, vmid: int) -> bool:
"""Принудительно остановить ВМ (через SSH - аналог kill -9)""" """Принудительно остановить ВМ (через SSH - аналог kill -9)"""
if not self.proxmox or not self.host: if not self.proxmox or not self.host:

View File

@@ -13,8 +13,9 @@ import {
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { useServers, useNodes } from '@/hooks/use-nodes'; import { useServers, useNodes } from '@/hooks/use-nodes';
import { useImageCatalog, useImageLXCTemplates, useNodeArch } from '@/hooks/use-image-catalog'; import { useImageCatalog, useImageLXCTemplates, useNodeArch, useNodeIsos } from '@/hooks/use-image-catalog';
import { useProfile } from '@/hooks/use-settings'; import { useProfile } from '@/hooks/use-settings';
import { formatBytes } from '@/lib/format';
import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog'; import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog';
import DownloadIsoDialog, { type IsoSource } from './DownloadIsoDialog'; import DownloadIsoDialog, { type IsoSource } from './DownloadIsoDialog';
import MirrorsManager from './MirrorsManager'; import MirrorsManager from './MirrorsManager';
@@ -47,6 +48,7 @@ export default function ImagesPage() {
const { data: catalog } = useImageCatalog(); const { data: catalog } = useImageCatalog();
const { data: lxcRepo = [] } = useImageLXCTemplates(serverId ?? undefined, node || undefined); const { data: lxcRepo = [] } = useImageLXCTemplates(serverId ?? undefined, node || undefined);
const { data: nodeArch } = useNodeArch(serverId ?? undefined, node || undefined); const { data: nodeArch } = useNodeArch(serverId ?? undefined, node || undefined);
const { data: nodeIsosResp } = useNodeIsos(serverId ?? undefined, node || undefined);
// Авто-выбор первого сервера и ноды // Авто-выбор первого сервера и ноды
useEffect(() => { useEffect(() => {
@@ -78,6 +80,14 @@ export default function ImagesPage() {
&& (!q || `${img.name} ${img.os ?? ''} ${img.version ?? ''}`.toLowerCase().includes(q))); && (!q || `${img.name} ${img.os ?? ''} ${img.version ?? ''}`.toLowerCase().includes(q)));
}, [catalog, search]); }, [catalog, search]);
// Реальные ISO-файлы на хранилищах выбранной ноды (в т.ч. существовавшие
// до подключения ноды к панели). Фильтр по поисковой строке.
const nodeIsos = useMemo(() => {
const all = nodeIsosResp?.isos ?? [];
const q = search.toLowerCase();
return all.filter((iso) => !q || `${iso.name} ${iso.volid}`.toLowerCase().includes(q));
}, [nodeIsosResp, search]);
const openIsoDownload = (src: IsoSource | null) => { const openIsoDownload = (src: IsoSource | null) => {
setIsoSource(src); setIsoSource(src);
setIsoDialogOpen(true); setIsoDialogOpen(true);
@@ -184,6 +194,31 @@ export default function ImagesPage() {
{!canDownload && ( {!canDownload && (
<div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы загрузить образ.</div> <div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы загрузить образ.</div>
)} )}
{/* Реальные ISO на хранилище ноды (загруженные ранее, в т.ч. до подключения к панели) */}
{canDownload && nodeIsos.length > 0 && (
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">На хранилище ноды</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{nodeIsos.map((iso) => (
<Card key={iso.volid}>
<CardContent className="flex items-center gap-3 p-4">
<OsLogo name={iso.name} />
<div className="min-w-0 flex-1">
<div className="font-medium truncate" title={iso.name}>{iso.name}</div>
<div className="flex items-center gap-1.5 mt-1">
<Badge variant="outline">{iso.storage}</Badge>
{iso.size != null && <Badge variant="secondary">{formatBytes(iso.size)}</Badge>}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Сохранённые ISO-ссылки из каталога/зеркал */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{isoImages.map((img) => ( {isoImages.map((img) => (
<Card key={img.id}> <Card key={img.id}>

View File

@@ -818,19 +818,26 @@ function ExecuteCommandDialog({ open, onClose, serverId, vmid, node, type }: Omi
function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) { function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'open' | 'onOpenChange'> & { open: boolean; onClose: () => void }) {
const [device, setDevice] = useState('ide2'); const [device, setDevice] = useState('ide2');
const [volid, setVolid] = useState<string>(''); const [volid, setVolid] = useState<string>('');
const [bootFromDisk, setBootFromDisk] = useState(true);
const [bootFromIso, setBootFromIso] = useState(false);
const isQemu = type === 'qemu'; const isQemu = type === 'qemu';
const isos = useNodeIsos(serverId, node, open && isQemu); const isos = useNodeIsos(serverId, node, open && isQemu);
const config = useVMConfig(serverId, vmid, type, node, open && isQemu); const config = useVMConfig(serverId, vmid, type, node, open && isQemu);
const attach = useAttachIso(serverId, vmid, node); const attach = useAttachIso(serverId, vmid, node);
const detach = useDetachIso(serverId, vmid, node); const detach = useDetachIso(serverId, vmid, node);
const currentIso = useMemo(() => { // volid текущего подключённого образа (без ',media=cdrom,size=...'); null — привод пуст.
const currentVolid = useMemo(() => {
const v = config.data?.[device]; const v = config.data?.[device];
if (typeof v !== 'string') return null; if (typeof v !== 'string' || v.startsWith('none')) return null;
if (v.startsWith('none')) return null; return v.split(',')[0] || null;
return v;
}, [config.data, device]); }, [config.data, device]);
// Отразить текущий образ в выпадающем списке при открытии/смене устройства.
useEffect(() => {
if (open) setVolid(currentVolid || '');
}, [open, device, currentVolid]);
if (!isQemu) { if (!isQemu) {
return ( return (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}> <Dialog open={open} onOpenChange={(o) => !o && onClose()}>
@@ -855,22 +862,17 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
<DialogDescription>Подключите ISO к виртуальному CD-ROM устройству.</DialogDescription> <DialogDescription>Подключите ISO к виртуальному CD-ROM устройству.</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-3"> <div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2"> <div>
<div> <Label>Устройство</Label>
<Label>Устройство</Label> <Select value={device} onValueChange={(v) => v && setDevice(v)}>
<Select value={device} onValueChange={(v) => v && setDevice(v)}> <SelectTrigger><SelectValue /></SelectTrigger>
<SelectTrigger><SelectValue /></SelectTrigger> <SelectContent>
<SelectContent> <SelectItem value="ide0">ide0</SelectItem>
<SelectItem value="ide0">ide0</SelectItem> <SelectItem value="ide1">ide1</SelectItem>
<SelectItem value="ide1">ide1</SelectItem> <SelectItem value="ide2">ide2</SelectItem>
<SelectItem value="ide2">ide2</SelectItem> <SelectItem value="ide3">ide3</SelectItem>
<SelectItem value="ide3">ide3</SelectItem> </SelectContent>
</SelectContent> </Select>
</Select>
</div>
<div className="text-xs text-muted-foreground self-end">
Сейчас: <span className="font-mono">{currentIso || 'пусто'}</span>
</div>
</div> </div>
<div> <div>
<Label>Образ</Label> <Label>Образ</Label>
@@ -880,7 +882,9 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{(isos.data?.isos || []).map((iso) => ( {(isos.data?.isos || []).map((iso) => (
<SelectItem key={iso.volid} value={iso.volid}>{iso.name || iso.volid}</SelectItem> <SelectItem key={iso.volid} value={iso.volid}>
{iso.name || iso.volid}{iso.volid === currentVolid ? ' · текущий' : ''}
</SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
@@ -888,22 +892,68 @@ function IsoDialog({ open, onClose, serverId, vmid, node, type }: Omit<Props, 'o
<p className="mt-1 text-xs text-muted-foreground">На ноде нет загруженных ISO образов.</p> <p className="mt-1 text-xs text-muted-foreground">На ноде нет загруженных ISO образов.</p>
)} )}
</div> </div>
{currentVolid && (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
className="mt-0.5"
checked={bootFromDisk}
onChange={(e) => setBootFromDisk(e.target.checked)}
/>
<span>
Загружаться с диска после извлечения
<span className="block text-xs text-muted-foreground">
Ставит диск первым в порядке загрузки и перезапускает ВМ, если она запущена, —
чтобы сразу стартовать с установленной ОС, а не с ISO.
</span>
</span>
</label>
)}
{volid && volid !== currentVolid && (
<label className="flex items-start gap-2 text-sm cursor-pointer">
<Checkbox
className="mt-0.5"
checked={bootFromIso}
onChange={(e) => setBootFromIso(e.target.checked)}
/>
<span>
Загрузиться с этого ISO
<span className="block text-xs text-muted-foreground">
Ставит CD-ROM первым в порядке загрузки и перезапускает ВМ, если она запущена, —
для установки/восстановления с образа. Без галочки ISO просто вставится в привод.
</span>
</span>
</label>
)}
</div> </div>
<DialogFooter> <DialogFooter>
<Button <Button
variant="outline" variant="outline"
onClick={() => detach.mutate({ device }, { onClick={() => detach.mutate({ device, boot_from_disk: bootFromDisk, reboot_after: bootFromDisk }, {
onSuccess: () => { toast.success('ISO отключён'); onClose(); }, onSuccess: (r) => {
toast.success(
r.rebooted ? 'ISO извлечён, ВМ перезапускается с диска'
: r.boot_from_disk ? 'ISO извлечён, загрузка с диска (применится при старте)'
: 'ISO отключён',
);
onClose();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
})} })}
disabled={detach.isPending || !currentIso} disabled={detach.isPending || !currentVolid}
> >
{detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} {detach.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Отключить Извлечь
</Button> </Button>
<Button <Button
onClick={() => attach.mutate({ volid, device }, { onClick={() => attach.mutate({ volid, device, boot_from_iso: bootFromIso, reboot_after: bootFromIso }, {
onSuccess: () => { toast.success('ISO подключён'); onClose(); }, onSuccess: (r) => {
toast.success(
r.rebooted ? 'ISO подключён, ВМ перезапускается с образа'
: r.boot_from_iso ? 'ISO подключён, загрузка с образа (применится при старте)'
: 'ISO подключён',
);
onClose();
},
onError: (e) => toast.error(e.message), onError: (e) => toast.error(e.message),
})} })}
disabled={!volid || attach.isPending} disabled={!volid || attach.isPending}

View File

@@ -50,6 +50,28 @@ export function useNodeArch(serverId?: number, node?: string) {
}); });
} }
// Реальные ISO-образы, уже лежащие на ISO-хранилищах ноды (в т.ч. загруженные
// до подключения ноды к панели). Отдельно от каталога/зеркал — это содержимое storage.
export interface NodeIso {
volid: string;
storage: string;
size: number | null;
format: string | null;
name: string;
}
export function useNodeIsos(serverId?: number, node?: string) {
return useQuery({
queryKey: ['node-isos', serverId, node],
queryFn: () =>
apiClient.get<{ isos: NodeIso[] }>(
`/proxmox/api/${serverId}/node/${encodeURIComponent(node!)}/isos`,
),
enabled: !!serverId && !!node,
staleTime: 60 * 1000,
});
}
// Целевые хранилища ноды по типу контента (import|vztmpl|iso). // Целевые хранилища ноды по типу контента (import|vztmpl|iso).
export function useImageStorages(serverId?: number, node?: string, content?: string) { export function useImageStorages(serverId?: number, node?: string, content?: string) {
return useQuery({ return useQuery({

View File

@@ -826,22 +826,34 @@ export function useNodeIsos(serverId: number, node: string, enabled = true) {
export function useAttachIso(serverId: number, vmid: number, node: string) { export function useAttachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<{ status: string }, Error, { volid: string; device?: string }>({ return useMutation<
{ status: string; boot_from_iso?: boolean; rebooted?: boolean },
Error,
{ volid: string; device?: string; boot_from_iso?: boolean; reboot_after?: boolean }
>({
mutationFn: (body) => mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/attach?node=${node}`, body), apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/attach?node=${node}`, body),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }); qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['resources'] });
}, },
}); });
} }
export function useDetachIso(serverId: number, vmid: number, node: string) { export function useDetachIso(serverId: number, vmid: number, node: string) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation<{ status: string }, Error, { device?: string }>({ return useMutation<
{ status: string; boot_from_disk?: boolean; rebooted?: boolean },
Error,
{ device?: string; boot_from_disk?: boolean; reboot_after?: boolean }
>({
mutationFn: (body) => mutationFn: (body) =>
apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/detach?node=${node}`, body || {}), apiClient.post(`/proxmox/api/${serverId}/vm/${vmid}/iso/detach?node=${node}`, body || {}),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) }); qc.invalidateQueries({ queryKey: vmKeys.config(serverId, vmid) });
qc.invalidateQueries({ queryKey: vmKeys.status(serverId, vmid) });
qc.invalidateQueries({ queryKey: ['resources'] });
}, },
}); });
} }

View File

@@ -617,7 +617,7 @@
"os_type": "Guest OS", "os_type": "Guest OS",
"os_type_hint": "Changing this presets BIOS, disk bus and network card", "os_type_hint": "Changing this presets BIOS, disk bus and network card",
"iso_ip_hint": "The IP is only reserved in IPAM — there is no cloud-init here, so enter it manually in the installer.", "iso_ip_hint": "The IP is only reserved in IPAM — there is no cloud-init here, so enter it manually in the installer.",
"iso_next_steps": "The VM boots from the ISO. Open the console to run the installer; when it is done, unmount the ISO and put the disk first in the boot order.", "iso_next_steps": "The VM boots from the ISO. Open the console to run the installer; when it is done, click Eject in the ISO dialog — the disk becomes first in the boot order automatically.",
"iso_download": "Download an ISO to this node", "iso_download": "Download an ISO to this node",
"iso_refresh": "Refresh the ISO list" "iso_refresh": "Refresh the ISO list"
}, },

View File

@@ -617,7 +617,7 @@
"os_type": "Гостевая ОС", "os_type": "Гостевая ОС",
"os_type_hint": "Выбор ОС подставляет BIOS, шину диска и сетевую карту", "os_type_hint": "Выбор ОС подставляет BIOS, шину диска и сетевую карту",
"iso_ip_hint": "IP только резервируется в IPAM — cloud-init здесь нет, адрес нужно ввести вручную в установщике.", "iso_ip_hint": "IP только резервируется в IPAM — cloud-init здесь нет, адрес нужно ввести вручную в установщике.",
"iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё отключите ISO и поставьте диск первым в порядке загрузки.", "iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё нажмите «Извлечь» в диалоге ISO — диск автоматически станет первым в порядке загрузки.",
"iso_download": "Загрузить ISO на эту ноду", "iso_download": "Загрузить ISO на эту ноду",
"iso_refresh": "Обновить список ISO" "iso_refresh": "Обновить список ISO"
}, },