feat(instances): ISO eject/mount with boot-order switch and hybrid reboot
- 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:
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user