feat(instances): create blank VMs for ISO installs and download ISOs
Add a third instance kind to the create wizard: a blank KVM VM with an empty disk, the installation ISO mounted on ide2 and put first in the boot order, so the guest boots straight into the installer over noVNC. Picking a Windows guest presets OVMF, a SATA disk and an e1000 NIC, which the installer sees without virtio drivers loaded. Also teach the image downloader content=iso, so ISOs can be fetched onto a node from the panel: by URL from the images page or from a saved mirror (mirrors now accept kind=iso), and straight from the ISO step of the wizard. The bundled catalog stays qcow2-only on purpose — install ISOs have no stable "latest" URLs, so hardcoded links would rot on every point release.
This commit is contained in:
@@ -8,6 +8,7 @@ from .backup import router as backup_router
|
|||||||
from .cluster import router as cluster_router
|
from .cluster import router as cluster_router
|
||||||
from .networks import router as networks_router
|
from .networks import router as networks_router
|
||||||
from .lxc import router as lxc_router
|
from .lxc import router as lxc_router
|
||||||
|
from .iso_vm import router as iso_vm_router
|
||||||
from .images import router as images_router
|
from .images import router as images_router
|
||||||
from .firewall import router as firewall_router
|
from .firewall import router as firewall_router
|
||||||
from .pools import router as pools_router
|
from .pools import router as pools_router
|
||||||
@@ -26,6 +27,7 @@ router.include_router(backup_router)
|
|||||||
router.include_router(cluster_router)
|
router.include_router(cluster_router)
|
||||||
router.include_router(networks_router)
|
router.include_router(networks_router)
|
||||||
router.include_router(lxc_router)
|
router.include_router(lxc_router)
|
||||||
|
router.include_router(iso_vm_router)
|
||||||
router.include_router(images_router)
|
router.include_router(images_router)
|
||||||
router.include_router(firewall_router)
|
router.include_router(firewall_router)
|
||||||
router.include_router(pools_router)
|
router.include_router(pools_router)
|
||||||
|
|||||||
@@ -794,6 +794,18 @@ def _normalize_import_filename(filename: str) -> str:
|
|||||||
return base + '.qcow2'
|
return base + '.qcow2'
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_iso_filename(filename: str) -> str:
|
||||||
|
"""Привести имя файла к допустимому для content=iso расширению.
|
||||||
|
|
||||||
|
Proxmox принимает в ISO-хранилище только `*.iso` и `*.img`; ссылки вида
|
||||||
|
`.../download?file=x` или архивные имена он отвергает ещё на старте задачи.
|
||||||
|
"""
|
||||||
|
low = filename.lower()
|
||||||
|
if low.endswith('.iso') or low.endswith('.img'):
|
||||||
|
return filename
|
||||||
|
return filename + '.iso'
|
||||||
|
|
||||||
|
|
||||||
def _wait_download_with_progress(client, task_id: int, node: str, upid: str,
|
def _wait_download_with_progress(client, task_id: int, node: str, upid: str,
|
||||||
lo: int = 15, hi: int = 100,
|
lo: int = 15, hi: int = 100,
|
||||||
timeout: int = 3600) -> bool:
|
timeout: int = 3600) -> bool:
|
||||||
@@ -843,12 +855,13 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
|
|||||||
url: Optional[str], template: Optional[str],
|
url: Optional[str], template: Optional[str],
|
||||||
checksum: Optional[str], checksum_algorithm: Optional[str],
|
checksum: Optional[str], checksum_algorithm: Optional[str],
|
||||||
user_id: int, username: str):
|
user_id: int, username: str):
|
||||||
"""Скачать образ (qcow2 или vztmpl) в хранилище ноды с прогрессом.
|
"""Скачать образ (qcow2, vztmpl или iso) в хранилище ноды с прогрессом.
|
||||||
|
|
||||||
kind:
|
kind:
|
||||||
'qcow2' — cloud-образ ВМ, content=import (url обязателен)
|
'qcow2' — cloud-образ ВМ, content=import (url обязателен)
|
||||||
'vztmpl' — LXC-шаблон: либо template (репозиторий Proxmox, aplinfo),
|
'vztmpl' — LXC-шаблон: либо template (репозиторий Proxmox, aplinfo),
|
||||||
либо url (произвольное зеркало, content=vztmpl)
|
либо url (произвольное зеркало, content=vztmpl)
|
||||||
|
'iso' — установочный ISO, content=iso (url обязателен)
|
||||||
"""
|
"""
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
server = None
|
server = None
|
||||||
@@ -864,8 +877,13 @@ def _do_image_download_sync(task_id: int, server_id: int, node: str, storage: st
|
|||||||
error='Не удалось подключиться к Proxmox серверу')
|
error='Не удалось подключиться к Proxmox серверу')
|
||||||
return
|
return
|
||||||
|
|
||||||
content = 'vztmpl' if kind == 'vztmpl' else 'import'
|
content = {'vztmpl': 'vztmpl', 'iso': 'iso'}.get(kind, 'import')
|
||||||
dl_filename = _normalize_import_filename(filename) if content == 'import' else filename
|
if content == 'import':
|
||||||
|
dl_filename = _normalize_import_filename(filename)
|
||||||
|
elif content == 'iso':
|
||||||
|
dl_filename = _normalize_iso_filename(filename)
|
||||||
|
else:
|
||||||
|
dl_filename = filename
|
||||||
|
|
||||||
# Идемпотентность: если файл уже есть в хранилище — не качаем повторно
|
# Идемпотентность: если файл уже есть в хранилище — не качаем повторно
|
||||||
existing_volid = f'{storage}:{content}/{dl_filename}'
|
existing_volid = f'{storage}:{content}/{dl_filename}'
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ class ImageDownloadRequest(BaseModel):
|
|||||||
# Либо ссылка на запись каталога/зеркала...
|
# Либо ссылка на запись каталога/зеркала...
|
||||||
source_id: Optional[str] = None
|
source_id: Optional[str] = None
|
||||||
# ...либо явные параметры произвольного образа:
|
# ...либо явные параметры произвольного образа:
|
||||||
kind: Optional[str] = None # 'qcow2' | 'vztmpl'
|
kind: Optional[str] = None # 'qcow2' | 'vztmpl' | 'iso'
|
||||||
url: Optional[str] = None
|
url: Optional[str] = None
|
||||||
template: Optional[str] = None # имя шаблона из репозитория (aplinfo)
|
template: Optional[str] = None # имя шаблона из репозитория (aplinfo)
|
||||||
filename: Optional[str] = None
|
filename: Optional[str] = None
|
||||||
@@ -82,7 +82,7 @@ def get_image_catalog(
|
|||||||
|
|
||||||
class ImageMirrorBase(BaseModel):
|
class ImageMirrorBase(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
kind: str = 'qcow2' # 'qcow2' | 'vztmpl'
|
kind: str = 'qcow2' # 'qcow2' | 'vztmpl' | 'iso'
|
||||||
os: Optional[str] = None
|
os: Optional[str] = None
|
||||||
version: Optional[str] = None
|
version: Optional[str] = None
|
||||||
arch: str = 'amd64'
|
arch: str = 'amd64'
|
||||||
@@ -95,12 +95,12 @@ class ImageMirrorBase(BaseModel):
|
|||||||
|
|
||||||
@model_validator(mode='after')
|
@model_validator(mode='after')
|
||||||
def _check(self):
|
def _check(self):
|
||||||
if self.kind not in ('qcow2', 'vztmpl'):
|
if self.kind not in ('qcow2', 'vztmpl', 'iso'):
|
||||||
raise ValueError("kind должен быть 'qcow2' или 'vztmpl'")
|
raise ValueError("kind должен быть 'qcow2', 'vztmpl' или 'iso'")
|
||||||
if not self.url and not self.template:
|
if not self.url and not self.template:
|
||||||
raise ValueError('Нужен url или template')
|
raise ValueError('Нужен url или template')
|
||||||
if self.kind == 'qcow2' and not self.url:
|
if self.kind in ('qcow2', 'iso') and not self.url:
|
||||||
raise ValueError('Для qcow2 нужен url')
|
raise ValueError(f'Для {self.kind} нужен url')
|
||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
@@ -305,10 +305,12 @@ async def download_image(
|
|||||||
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
raise HTTPException(status_code=404, detail="Proxmox server not found")
|
||||||
|
|
||||||
src = _resolve_source(db, req)
|
src = _resolve_source(db, req)
|
||||||
|
if src['kind'] not in ('qcow2', 'vztmpl', 'iso'):
|
||||||
|
raise HTTPException(status_code=400, detail=f"Неизвестный тип образа: {src['kind']}")
|
||||||
if src['kind'] == 'vztmpl' and not src['template'] and not src['url']:
|
if src['kind'] == 'vztmpl' and not src['template'] and not src['url']:
|
||||||
raise HTTPException(status_code=400, detail="Для vztmpl нужен url или template")
|
raise HTTPException(status_code=400, detail="Для vztmpl нужен url или template")
|
||||||
if src['kind'] != 'vztmpl' and not src['url']:
|
if src['kind'] != 'vztmpl' and not src['url']:
|
||||||
raise HTTPException(status_code=400, detail="Для qcow2 нужен url")
|
raise HTTPException(status_code=400, detail=f"Для {src['kind']} нужен url")
|
||||||
|
|
||||||
to_template = req.to_template and src['kind'] == 'qcow2'
|
to_template = req.to_template and src['kind'] == 'qcow2'
|
||||||
kind = 'image_template' if to_template else 'image_download'
|
kind = 'image_template' if to_template else 'image_download'
|
||||||
|
|||||||
373
backend/app/api/proxmox/iso_vm.py
Normal file
373
backend/app/api/proxmox/iso_vm.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
"""
|
||||||
|
API endpoints for creating blank KVM VMs prepared for an OS install from an ISO.
|
||||||
|
|
||||||
|
Unlike the template deploy (`/templates/api/deploy`) and the cloud-image import
|
||||||
|
(`images.py`), nothing is cloned here: the VM gets an empty disk, the ISO is
|
||||||
|
mounted as a CD-ROM and put first in the boot order, so the guest boots straight
|
||||||
|
into the installer via the noVNC console.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from loguru import logger
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...auth import PermissionChecker
|
||||||
|
from ...db import get_db, SessionLocal
|
||||||
|
from ...ipam_service import IPAMService
|
||||||
|
from ...logging_service import LoggingService
|
||||||
|
from ...models import DeployTask, IPAMNetwork, ProxmoxServer, User
|
||||||
|
from ...websocket_manager import broadcast_task_update
|
||||||
|
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_iso_vm_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="iso_vm")
|
||||||
|
|
||||||
|
DISK_BUSES = ("scsi", "sata", "virtio")
|
||||||
|
NET_MODELS = ("virtio", "e1000", "rtl8139", "vmxnet3")
|
||||||
|
BIOS_TYPES = ("seabios", "ovmf")
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow() -> datetime:
|
||||||
|
return datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class ISOVMCreateRequest(BaseModel):
|
||||||
|
server_id: int
|
||||||
|
node: str
|
||||||
|
name: str
|
||||||
|
iso: str # volid, e.g. "local:iso/ubuntu-24.04.iso"
|
||||||
|
vmid: Optional[int] = None
|
||||||
|
cores: int = 2
|
||||||
|
memory: int = 2048 # МБ
|
||||||
|
disk: int = 32 # ГБ
|
||||||
|
storage: str = "local-lvm"
|
||||||
|
bridge: str = "vmbr0"
|
||||||
|
ostype: str = "l26"
|
||||||
|
disk_bus: str = "scsi"
|
||||||
|
net_model: str = "virtio"
|
||||||
|
bios: str = "seabios"
|
||||||
|
tpm: bool = False
|
||||||
|
extra_iso: Optional[str] = None # второй CD-ROM, обычно virtio-win с драйверами
|
||||||
|
ip_address: Optional[str] = None
|
||||||
|
gateway: Optional[str] = None
|
||||||
|
ipam_network_id: Optional[int] = None
|
||||||
|
ipam_pool_id: Optional[int] = None
|
||||||
|
owner_id: Optional[int] = None
|
||||||
|
start_after_create: bool = True
|
||||||
|
onboot: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class ISOVMCreateResponse(BaseModel):
|
||||||
|
task_id: int
|
||||||
|
status: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _update_task(task_id: int, status: str, step: str, progress: int,
|
||||||
|
vmid: int = None, node: str = None, error: str = None):
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
task = db.query(DeployTask).filter(DeployTask.id == task_id).first()
|
||||||
|
if not task:
|
||||||
|
return
|
||||||
|
task.status = status
|
||||||
|
task.step = step
|
||||||
|
task.progress = progress
|
||||||
|
if vmid is not None:
|
||||||
|
task.vmid = vmid
|
||||||
|
if node is not None:
|
||||||
|
task.node = node
|
||||||
|
if error is not None:
|
||||||
|
task.error_message = error
|
||||||
|
if status == "running" and not task.started_at:
|
||||||
|
task.started_at = _utcnow()
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
task.completed_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
try:
|
||||||
|
broadcast_task_update(task.user_id, "deploy_task_update", task.to_dict())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _do_iso_vm_create(task_id: int, req: ISOVMCreateRequest, user_id: int,
|
||||||
|
username: str, owner_id: Optional[int] = None):
|
||||||
|
"""Blocking VM creation — runs in a thread pool."""
|
||||||
|
db = SessionLocal()
|
||||||
|
server = None
|
||||||
|
new_vmid = None
|
||||||
|
ipam_allocation = None
|
||||||
|
try:
|
||||||
|
_update_task(task_id, "running", "Подготовка...", 5)
|
||||||
|
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
|
||||||
|
if not server:
|
||||||
|
_update_task(task_id, "failed", "Сервер не найден", 0, error="Сервер Proxmox не найден")
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_task(task_id, "running", "Подключение к Proxmox...", 10)
|
||||||
|
client = _get_proxmox_client(server)
|
||||||
|
if not client.is_connected():
|
||||||
|
_update_task(task_id, "failed", "Ошибка подключения к Proxmox", 10,
|
||||||
|
error="Не удалось подключиться к Proxmox серверу")
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_task(task_id, "running", "Генерация VMID...", 20)
|
||||||
|
new_vmid = req.vmid or get_next_vmid(db, server.id)
|
||||||
|
|
||||||
|
# IPAM: адрес только резервируется — без cloud-init его придётся
|
||||||
|
# прописать вручную в установщике, поэтому он попадает в шаг задачи.
|
||||||
|
allocated_ip = req.ip_address
|
||||||
|
gateway = req.gateway
|
||||||
|
network_mask = "24"
|
||||||
|
if req.ipam_network_id and not req.ip_address:
|
||||||
|
_update_task(task_id, "running", "Резервирование IP-адреса (IPAM)...", 25,
|
||||||
|
vmid=new_vmid, node=req.node)
|
||||||
|
ipam = IPAMService(db)
|
||||||
|
ipam_allocation, error = ipam.auto_allocate_ip(
|
||||||
|
network_id=req.ipam_network_id,
|
||||||
|
pool_id=req.ipam_pool_id,
|
||||||
|
resource_type="vm",
|
||||||
|
resource_name=req.name,
|
||||||
|
proxmox_server_id=server.id,
|
||||||
|
proxmox_vmid=new_vmid,
|
||||||
|
proxmox_node=req.node,
|
||||||
|
allocated_by=username,
|
||||||
|
notes=f"Reserved for ISO install of VM {req.name}",
|
||||||
|
)
|
||||||
|
if not error:
|
||||||
|
allocated_ip = ipam_allocation.ip_address
|
||||||
|
elif req.ip_address and req.ipam_network_id:
|
||||||
|
ipam = IPAMService(db)
|
||||||
|
ipam_allocation, _ = ipam.allocate_ip(
|
||||||
|
ip_address=req.ip_address,
|
||||||
|
network_id=req.ipam_network_id,
|
||||||
|
pool_id=req.ipam_pool_id,
|
||||||
|
resource_type="vm",
|
||||||
|
resource_name=req.name,
|
||||||
|
proxmox_server_id=server.id,
|
||||||
|
proxmox_vmid=new_vmid,
|
||||||
|
proxmox_node=req.node,
|
||||||
|
allocated_by=username,
|
||||||
|
notes=f"Manually assigned for ISO install of VM {req.name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if req.ipam_network_id:
|
||||||
|
try:
|
||||||
|
network_id = req.ipam_network_id
|
||||||
|
if ipam_allocation and hasattr(ipam_allocation, "network_id"):
|
||||||
|
network_id = ipam_allocation.network_id
|
||||||
|
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||||||
|
if net:
|
||||||
|
if net.network and "/" in net.network:
|
||||||
|
network_mask = net.network.split("/")[1]
|
||||||
|
if not gateway and net.gateway:
|
||||||
|
gateway = net.gateway
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Could not get network info from IPAM: {e}")
|
||||||
|
|
||||||
|
# Create VM
|
||||||
|
_update_task(task_id, "running", f"Создание ВМ VMID {new_vmid}...", 40,
|
||||||
|
vmid=new_vmid, node=req.node)
|
||||||
|
upid = client.create_vm_from_iso(
|
||||||
|
node=req.node,
|
||||||
|
vmid=new_vmid,
|
||||||
|
name=req.name,
|
||||||
|
memory=req.memory,
|
||||||
|
cores=req.cores,
|
||||||
|
disk_storage=req.storage,
|
||||||
|
disk_size=req.disk,
|
||||||
|
iso_volid=req.iso,
|
||||||
|
bridge=req.bridge,
|
||||||
|
ostype=req.ostype,
|
||||||
|
disk_bus=req.disk_bus,
|
||||||
|
net_model=req.net_model,
|
||||||
|
bios=req.bios,
|
||||||
|
tpm=req.tpm,
|
||||||
|
extra_iso_volid=req.extra_iso,
|
||||||
|
onboot=req.onboot,
|
||||||
|
description=f"Created for ISO install ({req.iso}) by {username}",
|
||||||
|
)
|
||||||
|
if not upid:
|
||||||
|
_update_task(task_id, "failed", "Ошибка создания ВМ", 40,
|
||||||
|
error="Proxmox не вернул UPID задачи создания ВМ")
|
||||||
|
return
|
||||||
|
|
||||||
|
_update_task(task_id, "running", "Ожидание создания диска...", 60,
|
||||||
|
vmid=new_vmid, node=req.node)
|
||||||
|
if not client.wait_for_task(req.node, upid, timeout=300):
|
||||||
|
_update_task(task_id, "failed", "Таймаут создания ВМ", 60,
|
||||||
|
error="Создание ВМ заняло слишком долго (>5 мин)")
|
||||||
|
return
|
||||||
|
|
||||||
|
if req.start_after_create:
|
||||||
|
_update_task(task_id, "running", "Запуск ВМ...", 85, vmid=new_vmid, node=req.node)
|
||||||
|
client.start_vm(req.node, new_vmid)
|
||||||
|
|
||||||
|
_update_task(task_id, "running", "Сохранение в базе данных...", 95,
|
||||||
|
vmid=new_vmid, node=req.node)
|
||||||
|
save_vm_instance(
|
||||||
|
db=db,
|
||||||
|
server_id=server.id,
|
||||||
|
vmid=new_vmid,
|
||||||
|
node=req.node,
|
||||||
|
vm_type="qemu",
|
||||||
|
name=req.name,
|
||||||
|
cores=req.cores,
|
||||||
|
memory=req.memory,
|
||||||
|
disk_size=req.disk,
|
||||||
|
ip_address=allocated_ip,
|
||||||
|
ip_prefix=int(network_mask),
|
||||||
|
gateway=gateway,
|
||||||
|
template_id=None,
|
||||||
|
template_name=req.iso,
|
||||||
|
description=f"Created for ISO install from {req.iso}",
|
||||||
|
owner_id=owner_id or user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
LoggingService.log_proxmox_action(
|
||||||
|
db=db,
|
||||||
|
action="create",
|
||||||
|
resource_type="vm",
|
||||||
|
resource_id=new_vmid,
|
||||||
|
username=username,
|
||||||
|
resource_name=req.name,
|
||||||
|
server_id=server.id,
|
||||||
|
server_name=server.name,
|
||||||
|
node_name=req.node,
|
||||||
|
details={
|
||||||
|
"iso": req.iso,
|
||||||
|
"cores": req.cores,
|
||||||
|
"memory": req.memory,
|
||||||
|
"disk": req.disk,
|
||||||
|
"bios": req.bios,
|
||||||
|
"ip_address": allocated_ip,
|
||||||
|
},
|
||||||
|
success=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
done_step = "ВМ создана — откройте консоль для установки ОС"
|
||||||
|
if allocated_ip:
|
||||||
|
done_step = f"ВМ создана, IP зарезервирован: {allocated_ip}/{network_mask}"
|
||||||
|
_update_task(task_id, "completed", done_step, 100, vmid=new_vmid, node=req.node)
|
||||||
|
logger.info(f"[ISO VM] Task #{task_id}: {req.name} (VMID {new_vmid}) created by {username}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
err = str(e)
|
||||||
|
logger.error(f"[ISO VM] Task #{task_id} failed: {err}")
|
||||||
|
try:
|
||||||
|
if ipam_allocation is not None:
|
||||||
|
IPAMService(db).release_ip(ipam_allocation.id)
|
||||||
|
logger.info(f"[ISO VM] Task #{task_id}: Released IPAM IP {ipam_allocation.ip_address} on failure")
|
||||||
|
except Exception as ipam_err:
|
||||||
|
logger.warning(f"[ISO VM] Task #{task_id}: Could not release IPAM IP: {ipam_err}")
|
||||||
|
try:
|
||||||
|
if server:
|
||||||
|
LoggingService.log_proxmox_action(
|
||||||
|
db=db,
|
||||||
|
action="create",
|
||||||
|
resource_type="vm",
|
||||||
|
resource_id=new_vmid,
|
||||||
|
username=username,
|
||||||
|
resource_name=req.name,
|
||||||
|
server_id=server.id,
|
||||||
|
server_name=server.name,
|
||||||
|
node_name=req.node,
|
||||||
|
details={"iso": req.iso},
|
||||||
|
success=False,
|
||||||
|
error_message=err,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
_update_task(task_id, "failed", f"Ошибка: {err[:150]}", 0, error=err)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.post("/api/vm/create-from-iso", response_model=ISOVMCreateResponse, status_code=202)
|
||||||
|
async def create_vm_from_iso(
|
||||||
|
req: ISOVMCreateRequest,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(PermissionChecker("vm:create")),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Асинхронное создание пустой KVM ВМ с примонтированным установочным ISO.
|
||||||
|
Возвращает task_id — прогресс идёт тем же каналом, что и деплой из шаблона.
|
||||||
|
"""
|
||||||
|
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == req.server_id).first()
|
||||||
|
if not server:
|
||||||
|
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||||
|
if not req.name.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Укажите имя ВМ")
|
||||||
|
if not req.node:
|
||||||
|
raise HTTPException(status_code=400, detail="Укажите ноду для размещения ВМ")
|
||||||
|
if not req.iso:
|
||||||
|
raise HTTPException(status_code=400, detail="Выберите установочный ISO-образ")
|
||||||
|
if req.disk_bus not in DISK_BUSES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Шина диска должна быть одной из: {', '.join(DISK_BUSES)}")
|
||||||
|
if req.net_model not in NET_MODELS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Модель сетевой карты должна быть одной из: {', '.join(NET_MODELS)}")
|
||||||
|
if req.bios not in BIOS_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"BIOS должен быть одним из: {', '.join(BIOS_TYPES)}")
|
||||||
|
if req.disk < 1:
|
||||||
|
raise HTTPException(status_code=400, detail="Размер диска должен быть не меньше 1 GB")
|
||||||
|
|
||||||
|
# Resolve owner (admin may create the instance for another user)
|
||||||
|
is_admin = current_user.has_permission("user:manage") or current_user.is_admin
|
||||||
|
instance_owner_id = current_user.id
|
||||||
|
if req.owner_id and req.owner_id != current_user.id:
|
||||||
|
if not is_admin:
|
||||||
|
raise HTTPException(status_code=403, detail="Only admins can assign instance to another user")
|
||||||
|
owner = db.query(User).filter(User.id == req.owner_id).first()
|
||||||
|
if not owner:
|
||||||
|
raise HTTPException(status_code=404, detail="Owner user not found")
|
||||||
|
instance_owner_id = owner.id
|
||||||
|
|
||||||
|
# Enforce per-user resource quota (no-op if owner has no quota set)
|
||||||
|
from ...services.quota_service import check_quota
|
||||||
|
check_quota(db, instance_owner_id, add_cores=req.cores, add_memory_mb=req.memory, add_disk_gb=req.disk)
|
||||||
|
|
||||||
|
task = DeployTask(
|
||||||
|
status="pending",
|
||||||
|
kind="vm_iso",
|
||||||
|
step="В очереди...",
|
||||||
|
progress=0,
|
||||||
|
name=req.name,
|
||||||
|
server_id=server.id,
|
||||||
|
user_id=current_user.id,
|
||||||
|
created_at=_utcnow(),
|
||||||
|
)
|
||||||
|
db.add(task)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(task)
|
||||||
|
task_id = task.id
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
loop.run_in_executor(
|
||||||
|
_iso_vm_executor,
|
||||||
|
_do_iso_vm_create,
|
||||||
|
task_id,
|
||||||
|
req,
|
||||||
|
current_user.id,
|
||||||
|
current_user.username,
|
||||||
|
instance_owner_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ISOVMCreateResponse(task_id=task_id, status="pending", name=req.name)
|
||||||
@@ -141,6 +141,7 @@ class DeployTask(Base):
|
|||||||
kind = self.kind or 'deploy'
|
kind = self.kind or 'deploy'
|
||||||
descriptions = {
|
descriptions = {
|
||||||
'deploy': f"Создание {self.name}",
|
'deploy': f"Создание {self.name}",
|
||||||
|
'vm_iso': f"Создание ВМ под ISO: {self.name}",
|
||||||
'reinstall': f"Переустановка {self.name}",
|
'reinstall': f"Переустановка {self.name}",
|
||||||
'clone': f"Клонирование {self.name}",
|
'clone': f"Клонирование {self.name}",
|
||||||
'change_password': f"Смена пароля: {self.name}",
|
'change_password': f"Смена пароля: {self.name}",
|
||||||
|
|||||||
@@ -101,6 +101,86 @@ class VmMixin:
|
|||||||
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
|
logger.error(f"Ошибка обновления заметок VM {vmid}: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def create_vm_from_iso(self, node: str, vmid: int, name: str,
|
||||||
|
memory: int, cores: int,
|
||||||
|
disk_storage: str, disk_size: int,
|
||||||
|
iso_volid: Optional[str] = None,
|
||||||
|
bridge: str = 'vmbr0',
|
||||||
|
ostype: str = 'l26',
|
||||||
|
disk_bus: str = 'scsi',
|
||||||
|
net_model: str = 'virtio',
|
||||||
|
bios: str = 'seabios',
|
||||||
|
tpm: bool = False,
|
||||||
|
extra_iso_volid: Optional[str] = None,
|
||||||
|
onboot: bool = False,
|
||||||
|
description: Optional[str] = None) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Создать «пустую» ВМ под установку ОС с ISO.
|
||||||
|
|
||||||
|
В отличие от create_vm_with_import диск не импортируется из образа,
|
||||||
|
а аллоцируется пустым (`<storage>:<size_gb>`), ISO подключается
|
||||||
|
CD-ROM'ом на ide2 и ставится первым в порядке загрузки. Консоль —
|
||||||
|
графическая (vga=std), иначе установщик не будет виден в noVNC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
disk_size: размер пустого диска в GB
|
||||||
|
iso_volid: volid установочного ISO ('local:iso/ubuntu.iso'); None — без ISO
|
||||||
|
disk_bus: шина диска — scsi | sata | virtio (ide занят CD-ROM'ом)
|
||||||
|
net_model: модель сетевой карты (virtio | e1000 | rtl8139)
|
||||||
|
bios: seabios | ovmf (UEFI — добавляется efidisk0 и machine=q35)
|
||||||
|
tpm: добавить TPM 2.0 (требуется для Windows 11)
|
||||||
|
extra_iso_volid: второй ISO на ide0 (например, virtio-win с драйверами)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
UPID задачи создания ВМ
|
||||||
|
|
||||||
|
Исключение пробрасывается наверх — воркеру нужен текст ошибки Proxmox.
|
||||||
|
"""
|
||||||
|
if not self.proxmox:
|
||||||
|
return None
|
||||||
|
|
||||||
|
bus = disk_bus if disk_bus in ('scsi', 'sata', 'virtio') else 'scsi'
|
||||||
|
disk_dev = f'{bus}0'
|
||||||
|
disk_opts = ',discard=on' + (',iothread=1' if bus in ('scsi', 'virtio') else '')
|
||||||
|
|
||||||
|
params = {
|
||||||
|
'vmid': vmid,
|
||||||
|
'name': name,
|
||||||
|
'memory': memory,
|
||||||
|
'cores': cores,
|
||||||
|
'sockets': 1,
|
||||||
|
'cpu': 'host',
|
||||||
|
'ostype': ostype,
|
||||||
|
'scsihw': 'virtio-scsi-single',
|
||||||
|
disk_dev: f'{disk_storage}:{disk_size}{disk_opts}',
|
||||||
|
'net0': f'{net_model},bridge={bridge},firewall=1',
|
||||||
|
'agent': 'enabled=1',
|
||||||
|
'vga': 'std',
|
||||||
|
'onboot': 1 if onboot else 0,
|
||||||
|
}
|
||||||
|
if iso_volid:
|
||||||
|
params['ide2'] = f'{iso_volid},media=cdrom'
|
||||||
|
params['boot'] = f'order=ide2;{disk_dev}'
|
||||||
|
else:
|
||||||
|
params['boot'] = f'order={disk_dev}'
|
||||||
|
if extra_iso_volid:
|
||||||
|
# ide0 — второй привод под ISO с драйверами (ide2 занят основным)
|
||||||
|
params['ide0'] = f'{extra_iso_volid},media=cdrom'
|
||||||
|
if bios == 'ovmf':
|
||||||
|
params['bios'] = 'ovmf'
|
||||||
|
params['machine'] = 'q35'
|
||||||
|
# Ключи Secure Boot нужны Windows и мешают части Linux-установщиков
|
||||||
|
pre_enrolled = 1 if ostype.startswith('win') else 0
|
||||||
|
params['efidisk0'] = f'{disk_storage}:1,efitype=4m,pre-enrolled-keys={pre_enrolled}'
|
||||||
|
if tpm:
|
||||||
|
params['tpmstate0'] = f'{disk_storage}:1,version=v2.0'
|
||||||
|
if description:
|
||||||
|
params['description'] = description
|
||||||
|
|
||||||
|
result = self.proxmox.nodes(node).qemu.post(**params)
|
||||||
|
logger.info(f"Создание ВМ {vmid} ({name}) под установку с {iso_volid or 'без ISO'} на {node}, UPID: {result}")
|
||||||
|
return result
|
||||||
|
|
||||||
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
|
def attach_iso(self, node: str, vmid: int, iso_volid: str, device: str = 'ide2') -> bool:
|
||||||
"""Подключить ISO образ к VM (только KVM)."""
|
"""Подключить ISO образ к VM (только KVM)."""
|
||||||
if not self.proxmox:
|
if not self.proxmox:
|
||||||
|
|||||||
58
frontend/src/components/shared/ServerStatusBadge.tsx
Normal file
58
frontend/src/components/shared/ServerStatusBadge.tsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { StatusDot } from '@/components/shared/status-dot';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface ServerStatusBadgeProps {
|
||||||
|
isOnline?: boolean | null;
|
||||||
|
/** Почему сервер недоступен: 'auth' — ответил 401, 'connection' — не отвечает. */
|
||||||
|
errorKind?: 'auth' | 'connection' | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
/** 'badge' — плашка в списках серверов, 'inline' — точка с подписью. */
|
||||||
|
variant?: 'badge' | 'inline';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус подключения к серверу Proxmox.
|
||||||
|
*
|
||||||
|
* Отдельно показывает отказ авторизации: сервер жив и отвечает, но отверг
|
||||||
|
* учётные данные — это чинится не так, как «нода не отвечает», и не должно
|
||||||
|
* выглядеть одинаково.
|
||||||
|
*/
|
||||||
|
export function ServerStatusBadge({
|
||||||
|
isOnline, errorKind, lastError, variant = 'badge', className,
|
||||||
|
}: ServerStatusBadgeProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const authError = !isOnline && errorKind === 'auth';
|
||||||
|
const status = isOnline ? 'online' : authError ? 'auth_error' : 'offline';
|
||||||
|
const label = isOnline
|
||||||
|
? t('common.online')
|
||||||
|
: authError ? t('nodes.auth_error') : t('common.offline');
|
||||||
|
const title = authError ? t('nodes.auth_error_hint') : lastError || undefined;
|
||||||
|
|
||||||
|
if (variant === 'inline') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn('flex items-center gap-1.5 text-xs',
|
||||||
|
authError ? 'text-warning' : 'text-muted-foreground', className)}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<StatusDot status={status} pulse />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant={isOnline ? 'default' : authError ? 'outline' : 'destructive'}
|
||||||
|
className={cn('gap-1.5', authError && 'border-warning/40 text-warning', className)}
|
||||||
|
title={title}
|
||||||
|
>
|
||||||
|
<StatusDot status={status} pulse className="h-1.5 w-1.5" />
|
||||||
|
{label}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
219
frontend/src/features/images/DownloadIsoDialog.tsx
Normal file
219
frontend/src/features/images/DownloadIsoDialog.tsx
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Loader2, Download } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { useImageStorages, useDownloadImage } from '@/hooks/use-image-catalog';
|
||||||
|
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/** Предзаполнение из сохранённого зеркала; без него диалог работает по произвольному URL. */
|
||||||
|
export interface IsoSource {
|
||||||
|
source_id?: string;
|
||||||
|
name?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
serverId: number;
|
||||||
|
node: string;
|
||||||
|
source?: IsoSource | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtSize(bytes?: number): string {
|
||||||
|
if (!bytes || bytes <= 0) return '';
|
||||||
|
const gb = bytes / 1024 ** 3;
|
||||||
|
return `${gb.toFixed(0)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Имя файла из URL: хвост пути без query-строки. */
|
||||||
|
function filenameFromUrl(url: string): string {
|
||||||
|
const path = url.split('?')[0].split('#')[0];
|
||||||
|
return path.split('/').pop() || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DownloadIsoDialog({ open, onClose, serverId, node, source }: Props) {
|
||||||
|
const fromMirror = !!source?.source_id;
|
||||||
|
|
||||||
|
const [url, setUrl] = useState('');
|
||||||
|
const [filename, setFilename] = useState('');
|
||||||
|
const [storage, setStorage] = useState('');
|
||||||
|
const [checksum, setChecksum] = useState('');
|
||||||
|
const [checksumAlgo, setChecksumAlgo] = useState('sha256');
|
||||||
|
|
||||||
|
const { data: storages = [], isLoading: storagesLoading } = useImageStorages(
|
||||||
|
open ? serverId : undefined,
|
||||||
|
open ? node : undefined,
|
||||||
|
open ? 'iso' : undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
const download = useDownloadImage(serverId);
|
||||||
|
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setUrl(source?.url ?? '');
|
||||||
|
setFilename(source?.url ? filenameFromUrl(source.url) : '');
|
||||||
|
setChecksum('');
|
||||||
|
setChecksumAlgo('sha256');
|
||||||
|
}, [open, source?.url]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setStorage(storages.length === 1 ? storages[0].storage : '');
|
||||||
|
}, [open, storages]);
|
||||||
|
|
||||||
|
// Пользователь мог не тронуть имя — тогда ведём его за URL.
|
||||||
|
const effectiveFilename = filename.trim() || filenameFromUrl(url);
|
||||||
|
const submitDisabled =
|
||||||
|
!storage || download.isPending || (!fromMirror && (!url.trim() || !effectiveFilename));
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
if (submitDisabled) return;
|
||||||
|
download.mutate(
|
||||||
|
{
|
||||||
|
node,
|
||||||
|
storage,
|
||||||
|
kind: 'iso',
|
||||||
|
source_id: source?.source_id,
|
||||||
|
url: fromMirror ? undefined : url.trim(),
|
||||||
|
filename: fromMirror ? undefined : effectiveFilename,
|
||||||
|
checksum: checksum.trim() || undefined,
|
||||||
|
checksum_algorithm: checksum.trim() ? checksumAlgo : undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (data) => {
|
||||||
|
addDeployTask({
|
||||||
|
id: data.task_id,
|
||||||
|
name: data.name,
|
||||||
|
status: 'pending',
|
||||||
|
step: 'В очереди...',
|
||||||
|
progress: 0,
|
||||||
|
vmid: null,
|
||||||
|
node,
|
||||||
|
error_message: null,
|
||||||
|
kind: 'image_download',
|
||||||
|
server_id: serverId,
|
||||||
|
});
|
||||||
|
toast.success('Загрузка ISO запущена в фоне');
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (e: Error) => toast.error(e.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Загрузить ISO</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{fromMirror ? source?.name : 'Proxmox скачает образ по ссылке прямо на ноду'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Нода: <span className="font-medium text-foreground">{node}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!fromMirror && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="iso-url">URL образа</Label>
|
||||||
|
<Input
|
||||||
|
id="iso-url"
|
||||||
|
placeholder="https://releases.example.org/linux-24.04-amd64.iso"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="iso-filename">Имя файла в хранилище</Label>
|
||||||
|
<Input
|
||||||
|
id="iso-filename"
|
||||||
|
placeholder={filenameFromUrl(url) || 'linux-24.04-amd64.iso'}
|
||||||
|
value={filename}
|
||||||
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
Proxmox принимает только .iso и .img — иначе расширение будет добавлено автоматически.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label>Хранилище (iso)</Label>
|
||||||
|
<Select value={storage || '__none__'} onValueChange={(v) => { if (v !== null) setStorage(v === '__none__' ? '' : v); }}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder={storagesLoading ? 'Загрузка...' : 'Выберите хранилище'} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{storages.map((s) => (
|
||||||
|
<SelectItem key={s.storage} value={s.storage}>
|
||||||
|
{s.storage} ({s.type}){s.avail ? ` · ${fmtSize(s.avail)} свободно` : ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{!storagesLoading && storages.length === 0 && (
|
||||||
|
<p className="mt-2 text-sm text-warning">
|
||||||
|
Нет хранилища с типом контента ISO. Включите ISO в настройках хранилища.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[1fr_140px] gap-3">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="iso-checksum">Контрольная сумма (необязательно)</Label>
|
||||||
|
<Input
|
||||||
|
id="iso-checksum"
|
||||||
|
placeholder="e3b0c44298fc1c14..."
|
||||||
|
value={checksum}
|
||||||
|
onChange={(e) => setChecksum(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Алгоритм</Label>
|
||||||
|
<Select value={checksumAlgo} onValueChange={(v) => { if (v) setChecksumAlgo(v); }}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="sha256">sha256</SelectItem>
|
||||||
|
<SelectItem value="sha512">sha512</SelectItem>
|
||||||
|
<SelectItem value="sha1">sha1</SelectItem>
|
||||||
|
<SelectItem value="md5">md5</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onClose}>Отмена</Button>
|
||||||
|
<Button onClick={submit} disabled={submitDisabled}>
|
||||||
|
{download.isPending ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Download className="mr-2 h-4 w-4" />}
|
||||||
|
Скачать
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { Search, Download, HardDriveDownload, Package, ChevronDown } from 'lucide-react';
|
import { Search, Download, HardDriveDownload, Package, ChevronDown, Disc } from 'lucide-react';
|
||||||
import { OsLogo } from '@/features/templates/OsLogo';
|
import { OsLogo } from '@/features/templates/OsLogo';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -16,6 +16,7 @@ import { useServers, useNodes } from '@/hooks/use-nodes';
|
|||||||
import { useImageCatalog, useImageLXCTemplates, useNodeArch } from '@/hooks/use-image-catalog';
|
import { useImageCatalog, useImageLXCTemplates, useNodeArch } from '@/hooks/use-image-catalog';
|
||||||
import { useProfile } from '@/hooks/use-settings';
|
import { useProfile } from '@/hooks/use-settings';
|
||||||
import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog';
|
import DownloadImageDialog, { type SelectedImage } from './DownloadImageDialog';
|
||||||
|
import DownloadIsoDialog, { type IsoSource } from './DownloadIsoDialog';
|
||||||
import MirrorsManager from './MirrorsManager';
|
import MirrorsManager from './MirrorsManager';
|
||||||
import type { CatalogImage } from '@/types';
|
import type { CatalogImage } from '@/types';
|
||||||
|
|
||||||
@@ -29,9 +30,13 @@ export default function ImagesPage() {
|
|||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [cloudOpen, setCloudOpen] = useState(() => localStorage.getItem('images-cloud-open') !== '0');
|
const [cloudOpen, setCloudOpen] = useState(() => localStorage.getItem('images-cloud-open') !== '0');
|
||||||
const [lxcOpen, setLxcOpen] = useState(() => localStorage.getItem('images-lxc-open') !== '0');
|
const [lxcOpen, setLxcOpen] = useState(() => localStorage.getItem('images-lxc-open') !== '0');
|
||||||
|
const [isoOpen, setIsoOpen] = useState(() => localStorage.getItem('images-iso-open') !== '0');
|
||||||
|
const [isoSource, setIsoSource] = useState<IsoSource | null>(null);
|
||||||
|
const [isoDialogOpen, setIsoDialogOpen] = useState(false);
|
||||||
|
|
||||||
const toggleCloud = () => setCloudOpen((v) => { localStorage.setItem('images-cloud-open', v ? '0' : '1'); return !v; });
|
const toggleCloud = () => setCloudOpen((v) => { localStorage.setItem('images-cloud-open', v ? '0' : '1'); return !v; });
|
||||||
const toggleLxc = () => setLxcOpen((v) => { localStorage.setItem('images-lxc-open', v ? '0' : '1'); return !v; });
|
const toggleLxc = () => setLxcOpen((v) => { localStorage.setItem('images-lxc-open', v ? '0' : '1'); return !v; });
|
||||||
|
const toggleIso = () => setIsoOpen((v) => { localStorage.setItem('images-iso-open', v ? '0' : '1'); return !v; });
|
||||||
|
|
||||||
const { data: servers = [] } = useServers();
|
const { data: servers = [] } = useServers();
|
||||||
const { data: profile } = useProfile();
|
const { data: profile } = useProfile();
|
||||||
@@ -65,6 +70,19 @@ export default function ImagesPage() {
|
|||||||
});
|
});
|
||||||
}, [catalog, archFilter, search]);
|
}, [catalog, archFilter, search]);
|
||||||
|
|
||||||
|
// ISO во встроенном каталоге нет — ссылки на дистрибутивы живут в зеркалах пользователя.
|
||||||
|
const isoImages = useMemo<CatalogImage[]>(() => {
|
||||||
|
const all = [...(catalog?.builtin ?? []), ...(catalog?.mirrors ?? [])];
|
||||||
|
const q = search.toLowerCase();
|
||||||
|
return all.filter((img) => img.kind === 'iso'
|
||||||
|
&& (!q || `${img.name} ${img.os ?? ''} ${img.version ?? ''}`.toLowerCase().includes(q)));
|
||||||
|
}, [catalog, search]);
|
||||||
|
|
||||||
|
const openIsoDownload = (src: IsoSource | null) => {
|
||||||
|
setIsoSource(src);
|
||||||
|
setIsoDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const lxcTemplates = useMemo(() => {
|
const lxcTemplates = useMemo(() => {
|
||||||
const q = search.toLowerCase();
|
const q = search.toLowerCase();
|
||||||
return lxcRepo.filter((t) => !q || `${t.template} ${t.os ?? ''} ${t.headline ?? ''}`.toLowerCase().includes(q));
|
return lxcRepo.filter((t) => !q || `${t.template} ${t.os ?? ''} ${t.headline ?? ''}`.toLowerCase().includes(q));
|
||||||
@@ -149,6 +167,48 @@ export default function ImagesPage() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ISO-образы (установочные) */}
|
||||||
|
<section className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<button type="button" onClick={toggleIso}
|
||||||
|
className="flex items-center gap-2 text-lg font-semibold">
|
||||||
|
<ChevronDown className={`h-5 w-5 transition-transform ${isoOpen ? '' : '-rotate-90'}`} />
|
||||||
|
<Disc className="h-5 w-5" /> ISO-образы
|
||||||
|
</button>
|
||||||
|
<Button size="sm" variant="outline" disabled={!canDownload}
|
||||||
|
onClick={() => openIsoDownload(null)}>
|
||||||
|
<Download className="mr-2 h-4 w-4" /> Загрузить по URL
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{isoOpen && (<>
|
||||||
|
{!canDownload && (
|
||||||
|
<div className="text-sm text-muted-foreground">Выберите сервер и ноду, чтобы загрузить образ.</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{isoImages.map((img) => (
|
||||||
|
<Card key={img.id}>
|
||||||
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
|
<OsLogo name={img.os || img.name} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="font-medium truncate">{img.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground truncate" title={img.url ?? ''}>{img.url}</div>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" disabled={!canDownload}
|
||||||
|
onClick={() => openIsoDownload({ source_id: img.id, name: img.name, url: img.url ?? undefined })}>
|
||||||
|
<Download className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{isoImages.length === 0 && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
Сохранённых ISO-ссылок нет. Загрузите образ по URL или добавьте зеркало с типом «iso» ниже.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>)}
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* LXC-шаблоны (репозиторий Proxmox) */}
|
{/* LXC-шаблоны (репозиторий Proxmox) */}
|
||||||
<section className="space-y-3">
|
<section className="space-y-3">
|
||||||
<button type="button" onClick={toggleLxc}
|
<button type="button" onClick={toggleLxc}
|
||||||
@@ -189,6 +249,14 @@ export default function ImagesPage() {
|
|||||||
node={node}
|
node={node}
|
||||||
image={selected}
|
image={selected}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DownloadIsoDialog
|
||||||
|
open={isoDialogOpen}
|
||||||
|
onClose={() => setIsoDialogOpen(false)}
|
||||||
|
serverId={serverId ?? 0}
|
||||||
|
node={node}
|
||||||
|
source={isoSource}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ function MirrorFormDialog({
|
|||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
if (!form.name.trim()) return;
|
if (!form.name.trim()) return;
|
||||||
if (form.kind === 'qcow2' && !form.url?.trim()) {
|
if ((form.kind === 'qcow2' || form.kind === 'iso') && !form.url?.trim()) {
|
||||||
toast.error('Для qcow2 нужен URL');
|
toast.error(`Для ${form.kind} нужен URL`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!form.url?.trim() && !form.template?.trim()) {
|
if (!form.url?.trim() && !form.template?.trim()) {
|
||||||
@@ -110,6 +110,7 @@ function MirrorFormDialog({
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="qcow2">qcow2 (ВМ)</SelectItem>
|
<SelectItem value="qcow2">qcow2 (ВМ)</SelectItem>
|
||||||
<SelectItem value="vztmpl">vztmpl (LXC)</SelectItem>
|
<SelectItem value="vztmpl">vztmpl (LXC)</SelectItem>
|
||||||
|
<SelectItem value="iso">iso (установочный)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,8 +134,13 @@ function MirrorFormDialog({
|
|||||||
<Input id="m-ver" placeholder="24.04" value={form.version} onChange={(e) => set('version', e.target.value)} />
|
<Input id="m-ver" placeholder="24.04" value={form.version} onChange={(e) => set('version', e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="m-url">URL {form.kind === 'qcow2' ? '(обязательно)' : '(или имя шаблона ниже)'}</Label>
|
<Label htmlFor="m-url">URL {form.kind === 'vztmpl' ? '(или имя шаблона ниже)' : '(обязательно)'}</Label>
|
||||||
<Input id="m-url" placeholder="https://.../image.qcow2" value={form.url} onChange={(e) => set('url', e.target.value)} />
|
<Input
|
||||||
|
id="m-url"
|
||||||
|
placeholder={form.kind === 'iso' ? 'https://.../installer-amd64.iso' : 'https://.../image.qcow2'}
|
||||||
|
value={form.url}
|
||||||
|
onChange={(e) => set('url', e.target.value)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
{form.kind === 'vztmpl' && (
|
{form.kind === 'vztmpl' && (
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,13 +2,13 @@ import { useState } from 'react';
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge } from 'lucide-react';
|
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive, KeyRound, Gauge, Disc, Download, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { ServerStatusBadge } from '@/components/shared/ServerStatusBadge';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { useServers, useNodes } from '@/hooks/use-nodes';
|
import { useServers, useNodes } from '@/hooks/use-nodes';
|
||||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||||
@@ -16,6 +16,8 @@ import { useIPAMNetworks } from '@/hooks/use-ipam';
|
|||||||
import { scopedNetworks, autoPickNetworkId } from '@/features/ipam/network-scope';
|
import { scopedNetworks, autoPickNetworkId } from '@/features/ipam/network-scope';
|
||||||
import { useWorkspaceStore } from '@/stores/workspace-store';
|
import { useWorkspaceStore } from '@/stores/workspace-store';
|
||||||
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||||
|
import { useNodeIsos, useCreateIsoVM } from '@/hooks/use-instances';
|
||||||
|
import DownloadIsoDialog from '@/features/images/DownloadIsoDialog';
|
||||||
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
|
import { useMySSHKeys, useUserSSHKeys } from '@/hooks/use-ssh-keys';
|
||||||
import { useProfile, useMyQuota } from '@/hooks/use-settings';
|
import { useProfile, useMyQuota } from '@/hooks/use-settings';
|
||||||
import { useUsers } from '@/hooks/use-users';
|
import { useUsers } from '@/hooks/use-users';
|
||||||
@@ -24,10 +26,20 @@ import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
|||||||
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
|
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
type InstanceKind = 'vm' | 'lxc';
|
type InstanceKind = 'vm' | 'lxc' | 'iso';
|
||||||
const STEPS = ['server', 'type', 'template', 'config', 'confirm'] as const;
|
const STEPS = ['server', 'type', 'template', 'config', 'confirm'] as const;
|
||||||
type Step = (typeof STEPS)[number];
|
type Step = (typeof STEPS)[number];
|
||||||
|
|
||||||
|
// ostype Proxmox → подпись в списке. l26 покрывает все современные ядра Linux.
|
||||||
|
const OS_TYPES = [
|
||||||
|
{ value: 'l26', label: 'Linux (5.x/6.x)' },
|
||||||
|
{ value: 'win11', label: 'Windows 11 / 2022' },
|
||||||
|
{ value: 'win10', label: 'Windows 10 / 2016-2019' },
|
||||||
|
{ value: 'win8', label: 'Windows 8 / 2012' },
|
||||||
|
{ value: 'solaris', label: 'Solaris' },
|
||||||
|
{ value: 'other', label: 'Other' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
|
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -95,6 +107,29 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
onboot: false,
|
onboot: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── ISO (пустая ВМ под установку ОС) state ────────────────────────────────
|
||||||
|
const [isoDownloadOpen, setIsoDownloadOpen] = useState(false);
|
||||||
|
const [isoConfig, setIsoConfig] = useState({
|
||||||
|
name: '',
|
||||||
|
iso: '',
|
||||||
|
extra_iso: '',
|
||||||
|
cores: 2,
|
||||||
|
memory: 4096,
|
||||||
|
disk: 32,
|
||||||
|
storage: 'local-lvm',
|
||||||
|
bridge: 'vmbr0',
|
||||||
|
ostype: 'l26' as string,
|
||||||
|
disk_bus: 'scsi' as 'scsi' | 'sata' | 'virtio',
|
||||||
|
net_model: 'virtio' as 'virtio' | 'e1000' | 'rtl8139' | 'vmxnet3',
|
||||||
|
bios: 'seabios' as 'seabios' | 'ovmf',
|
||||||
|
tpm: false,
|
||||||
|
ipam_network_id: null as number | null,
|
||||||
|
ip_address: '',
|
||||||
|
gateway: '',
|
||||||
|
start_after_create: true,
|
||||||
|
onboot: false,
|
||||||
|
});
|
||||||
|
|
||||||
// Queries
|
// Queries
|
||||||
const { data: servers = [] } = useServers();
|
const { data: servers = [] } = useServers();
|
||||||
const { data: groups = [] } = useTemplateGroups();
|
const { data: groups = [] } = useTemplateGroups();
|
||||||
@@ -105,6 +140,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
const { data: vmStorages = [] } = useLXCStorages(selectedServer?.id, selectedNode || undefined, 'images');
|
const { data: vmStorages = [] } = useLXCStorages(selectedServer?.id, selectedNode || undefined, 'images');
|
||||||
const { data: nodesData } = useNodes(selectedServer?.id ?? 0);
|
const { data: nodesData } = useNodes(selectedServer?.id ?? 0);
|
||||||
const nodes = nodesData?.nodes ?? [];
|
const nodes = nodesData?.nodes ?? [];
|
||||||
|
const isoList = useNodeIsos(selectedServer?.id ?? 0, selectedNode, kind === 'iso' && !!selectedNode);
|
||||||
const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId);
|
const activeWorkspaceId = useWorkspaceStore((s) => s.activeWorkspaceId);
|
||||||
|
|
||||||
// Нода нужна только для размещения; сеть подбирается по активной рабочей области.
|
// Нода нужна только для размещения; сеть подбирается по активной рабочей области.
|
||||||
@@ -146,6 +182,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
});
|
});
|
||||||
|
|
||||||
const deployLXC = useDeployLXC();
|
const deployLXC = useDeployLXC();
|
||||||
|
const createIsoVM = useCreateIsoVM();
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
const i = STEPS.indexOf(step);
|
const i = STEPS.indexOf(step);
|
||||||
@@ -159,12 +196,86 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
const canNext = () => {
|
const canNext = () => {
|
||||||
if (step === 'server') return !!selectedServer;
|
if (step === 'server') return !!selectedServer;
|
||||||
if (step === 'type') return !!kind;
|
if (step === 'type') return !!kind;
|
||||||
if (step === 'template') return kind === 'vm' ? !!selectedTemplate : !!selectedLXCTemplate;
|
if (step === 'template') {
|
||||||
if (step === 'config') return kind === 'vm' ? config.name.length >= 2 : lxcConfig.name.length >= 2;
|
if (kind === 'vm') return !!selectedTemplate;
|
||||||
|
if (kind === 'iso') return !!selectedNode && !!isoConfig.iso;
|
||||||
|
return !!selectedLXCTemplate;
|
||||||
|
}
|
||||||
|
if (step === 'config') {
|
||||||
|
if (kind === 'vm') return config.name.length >= 2;
|
||||||
|
if (kind === 'iso') return isoConfig.name.length >= 2;
|
||||||
|
return lxcConfig.name.length >= 2;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Windows не грузится с SeaBIOS+VirtIO «из коробки»: UEFI, SATA-диск и e1000
|
||||||
|
// дают установщику увидеть диск и сеть без подгрузки virtio-драйверов.
|
||||||
|
const selectOsType = (ostype: string) => {
|
||||||
|
setIsoConfig(p => {
|
||||||
|
if (!ostype.startsWith('win')) {
|
||||||
|
return { ...p, ostype, bios: 'seabios', tpm: false, disk_bus: 'scsi', net_model: 'virtio' };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
ostype,
|
||||||
|
bios: 'ovmf',
|
||||||
|
tpm: ostype === 'win11',
|
||||||
|
disk_bus: 'sata',
|
||||||
|
net_model: 'e1000',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleDeploy = () => {
|
const handleDeploy = () => {
|
||||||
|
if (kind === 'iso') {
|
||||||
|
if (!selectedServer || !selectedNode || !isoConfig.iso) return;
|
||||||
|
createIsoVM.mutate(
|
||||||
|
{
|
||||||
|
server_id: selectedServer.id,
|
||||||
|
node: selectedNode,
|
||||||
|
name: isoConfig.name,
|
||||||
|
iso: isoConfig.iso,
|
||||||
|
extra_iso: isoConfig.extra_iso || undefined,
|
||||||
|
cores: isoConfig.cores,
|
||||||
|
memory: isoConfig.memory,
|
||||||
|
disk: isoConfig.disk,
|
||||||
|
storage: isoConfig.storage,
|
||||||
|
bridge: isoConfig.bridge,
|
||||||
|
ostype: isoConfig.ostype,
|
||||||
|
disk_bus: isoConfig.disk_bus,
|
||||||
|
net_model: isoConfig.net_model,
|
||||||
|
bios: isoConfig.bios,
|
||||||
|
tpm: isoConfig.tpm,
|
||||||
|
ip_address: isoConfig.ip_address || undefined,
|
||||||
|
gateway: isoConfig.gateway || undefined,
|
||||||
|
ipam_network_id: isoConfig.ipam_network_id || undefined,
|
||||||
|
owner_id: effectiveOwnerId ?? undefined,
|
||||||
|
start_after_create: isoConfig.start_after_create,
|
||||||
|
onboot: isoConfig.onboot,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: (res) => {
|
||||||
|
addDeployTask({
|
||||||
|
id: res.task_id,
|
||||||
|
name: res.name,
|
||||||
|
status: 'pending',
|
||||||
|
step: t('wizard.deploy_starting'),
|
||||||
|
progress: 0,
|
||||||
|
vmid: null,
|
||||||
|
node: selectedNode,
|
||||||
|
error_message: null,
|
||||||
|
kind: 'vm_iso',
|
||||||
|
server_id: selectedServer.id,
|
||||||
|
});
|
||||||
|
toast.success(t('wizard.deploy_queued', { name: res.name }));
|
||||||
|
navigate('/instances');
|
||||||
|
},
|
||||||
|
onError: (err: Error) => toast.error(err.message),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (kind === 'lxc') {
|
if (kind === 'lxc') {
|
||||||
if (!selectedLXCTemplate || !selectedServer) return;
|
if (!selectedLXCTemplate || !selectedServer) return;
|
||||||
deployLXC.mutate(
|
deployLXC.mutate(
|
||||||
@@ -282,6 +393,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
const nid = autoPickNetworkId(networks, activeWorkspaceId);
|
const nid = autoPickNetworkId(networks, activeWorkspaceId);
|
||||||
setConfig(p => ({ ...p, ipam_network_id: nid }));
|
setConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||||
setLxcConfig(p => ({ ...p, ipam_network_id: nid }));
|
setLxcConfig(p => ({ ...p, ipam_network_id: nid }));
|
||||||
|
setIsoConfig(p => ({ ...p, ipam_network_id: nid, iso: '', extra_iso: '' }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-center gap-3 p-4">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
@@ -290,9 +402,11 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
<p className="font-medium truncate">{srv.name}</p>
|
<p className="font-medium truncate">{srv.name}</p>
|
||||||
<p className="text-sm text-muted-foreground font-mono">{srv.ip_address}:{srv.port}</p>
|
<p className="text-sm text-muted-foreground font-mono">{srv.ip_address}:{srv.port}</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge variant={srv.is_online ? 'default' : 'destructive'}>
|
<ServerStatusBadge
|
||||||
{srv.is_online ? t('common.online') : t('common.offline')}
|
isOnline={srv.is_online}
|
||||||
</Badge>
|
errorKind={srv.last_error_kind}
|
||||||
|
lastError={srv.last_error}
|
||||||
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -304,7 +418,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
{step === 'type' && (
|
{step === 'type' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">{t('wizard.select_type')}</h3>
|
<h3 className="text-lg font-semibold">{t('wizard.select_type')}</h3>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<Card
|
<Card
|
||||||
interactive
|
interactive
|
||||||
className={kind === 'vm' ? 'border-primary bg-primary/5' : ''}
|
className={kind === 'vm' ? 'border-primary bg-primary/5' : ''}
|
||||||
@@ -333,6 +447,78 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
{kind === 'lxc' && <CheckCircle className="ml-auto h-5 w-5 text-success shrink-0" />}
|
{kind === 'lxc' && <CheckCircle className="ml-auto h-5 w-5 text-success shrink-0" />}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card
|
||||||
|
interactive
|
||||||
|
className={kind === 'iso' ? 'border-primary bg-primary/5' : ''}
|
||||||
|
onClick={() => setKind('iso')}
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-4 p-5">
|
||||||
|
<Disc className="h-10 w-10 text-primary shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{t('wizard.type_iso')}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('wizard.type_iso_desc')}</p>
|
||||||
|
</div>
|
||||||
|
{kind === 'iso' && <CheckCircle className="ml-auto h-5 w-5 text-primary shrink-0" />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: ISO selection — пустая ВМ под установку ОС */}
|
||||||
|
{step === 'template' && kind === 'iso' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{t('wizard.select_iso')}</h3>
|
||||||
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.target_node')}</Label>
|
||||||
|
<Select value={selectedNode} onValueChange={v => { setSelectedNode(v ?? ''); setIsoConfig(p => ({ ...p, iso: '', extra_iso: '' })); }}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue placeholder={t('wizard.select_node_first')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{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('wizard.iso_node_hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.iso_image')}</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Select value={isoConfig.iso} onValueChange={v => setIsoConfig(p => ({ ...p, iso: v ?? '' }))}>
|
||||||
|
<SelectTrigger className="flex-1">
|
||||||
|
<SelectValue placeholder={isoList.isLoading ? t('common.loading') : t('wizard.iso_select_placeholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(isoList.data?.isos || []).map(iso => (
|
||||||
|
<SelectItem key={iso.volid} value={iso.volid}>{iso.name || iso.volid}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button variant="outline" size="icon" disabled={!selectedNode || isoList.isFetching}
|
||||||
|
title={t('wizard.iso_refresh')} onClick={() => isoList.refetch()}>
|
||||||
|
<RefreshCw className={`h-4 w-4 ${isoList.isFetching ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{selectedNode && !isoList.isLoading && (isoList.data?.isos?.length ?? 0) === 0 && (
|
||||||
|
<p className="mt-1 text-xs text-warning">{t('wizard.no_isos')}</p>
|
||||||
|
)}
|
||||||
|
<Button variant="link" size="sm" className="mt-1 h-auto p-0" disabled={!selectedNode}
|
||||||
|
onClick={() => setIsoDownloadOpen(true)}>
|
||||||
|
<Download className="mr-1 h-3.5 w-3.5" /> {t('wizard.iso_download')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label>{t('wizard.iso_extra')}</Label>
|
||||||
|
<Select value={isoConfig.extra_iso || '__none__'} onValueChange={v => setIsoConfig(p => ({ ...p, extra_iso: v && v !== '__none__' ? v : '' }))}>
|
||||||
|
<SelectTrigger className="mt-1"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="__none__">{t('wizard.iso_extra_none')}</SelectItem>
|
||||||
|
{(isoList.data?.isos || []).map(iso => (
|
||||||
|
<SelectItem key={iso.volid} value={iso.volid}>{iso.name || iso.volid}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('wizard.iso_extra_hint')}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -580,6 +766,176 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 4: Configuration — ISO */}
|
||||||
|
{step === 'config' && kind === 'iso' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||||
|
<QuotaHint addCores={isoConfig.cores} addMemoryMb={isoConfig.memory} addDiskGb={isoConfig.disk} />
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
|
{/* Basic */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.basic')}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>{t('common.name')}</Label>
|
||||||
|
<Input value={isoConfig.name} onChange={e => setIsoConfig(p => ({ ...p, name: e.target.value }))} placeholder={t('common.placeholder_vm_name')} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.os_type')}</Label>
|
||||||
|
<Select value={isoConfig.ostype} onValueChange={v => v && selectOsType(v)}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{OS_TYPES.map(o => <SelectItem key={o.value} value={o.value}>{o.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('wizard.os_type_hint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="iso_start_after"
|
||||||
|
checked={isoConfig.start_after_create}
|
||||||
|
onChange={e => setIsoConfig(p => ({ ...p, start_after_create: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="iso_start_after">{t('wizard.start_after_create')}</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="iso_onboot"
|
||||||
|
checked={isoConfig.onboot}
|
||||||
|
onChange={e => setIsoConfig(p => ({ ...p, onboot: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="iso_onboot">{t('instances.start_on_boot')}</Label>
|
||||||
|
</div>
|
||||||
|
{isAdmin && (
|
||||||
|
<SSHOwnerSelector
|
||||||
|
users={allUsers}
|
||||||
|
value={ownerId}
|
||||||
|
onChange={(v) => setOwnerId(v)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Resources */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Cpu className="h-4 w-4" />{t('wizard.resources')}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>vCPU</Label>
|
||||||
|
<Input type="number" min={1} value={isoConfig.cores} onChange={e => setIsoConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.memory_mb')}</Label>
|
||||||
|
<Input type="number" min={256} step={256} value={isoConfig.memory} onChange={e => setIsoConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.disk_gb')}</Label>
|
||||||
|
<Input type="number" min={1} value={isoConfig.disk} onChange={e => setIsoConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.storage')}</Label>
|
||||||
|
<Select value={isoConfig.storage} onValueChange={v => setIsoConfig(p => ({ ...p, storage: v ?? '' }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vmStorages.length > 0
|
||||||
|
? vmStorages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)
|
||||||
|
: <SelectItem value="local-lvm">local-lvm</SelectItem>
|
||||||
|
}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Hardware */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm flex items-center gap-2"><Disc className="h-4 w-4" />{t('wizard.iso_hardware')}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.disk_bus')}</Label>
|
||||||
|
<Select value={isoConfig.disk_bus} onValueChange={v => v && setIsoConfig(p => ({ ...p, disk_bus: v as typeof p.disk_bus }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="scsi">VirtIO SCSI</SelectItem>
|
||||||
|
<SelectItem value="sata">SATA</SelectItem>
|
||||||
|
<SelectItem value="virtio">VirtIO Block</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{isoConfig.ostype.startsWith('win') && isoConfig.disk_bus !== 'sata' && (
|
||||||
|
<p className="mt-1 text-xs text-warning">{t('wizard.disk_bus_windows_hint')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>BIOS</Label>
|
||||||
|
<Select value={isoConfig.bios} onValueChange={v => v && setIsoConfig(p => ({ ...p, bios: v as typeof p.bios }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="seabios">SeaBIOS (Legacy)</SelectItem>
|
||||||
|
<SelectItem value="ovmf">OVMF (UEFI)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.net_model')}</Label>
|
||||||
|
<Select value={isoConfig.net_model} onValueChange={v => v && setIsoConfig(p => ({ ...p, net_model: v as typeof p.net_model }))}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="virtio">VirtIO</SelectItem>
|
||||||
|
<SelectItem value="e1000">Intel E1000</SelectItem>
|
||||||
|
<SelectItem value="rtl8139">Realtek RTL8139</SelectItem>
|
||||||
|
<SelectItem value="vmxnet3">VMware vmxnet3</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id="iso_tpm"
|
||||||
|
checked={isoConfig.tpm}
|
||||||
|
onChange={e => setIsoConfig(p => ({ ...p, tpm: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="iso_tpm">{t('wizard.tpm')}</Label>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Network */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.network')}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<Label>{t('wizard.bridge')}</Label>
|
||||||
|
<Input value={isoConfig.bridge} onChange={e => setIsoConfig(p => ({ ...p, bridge: e.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>IPAM {t('wizard.network')}</Label>
|
||||||
|
<Select value={String(isoConfig.ipam_network_id || '')} onValueChange={v => setIsoConfig(p => ({ ...p, ipam_network_id: v ? Number(v) : null }))}>
|
||||||
|
<SelectTrigger><SelectValue placeholder={t('wizard.manual_ip')} /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{vmFilteredNetworks.map(n => (
|
||||||
|
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{!isoConfig.ipam_network_id && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<Label>IP</Label>
|
||||||
|
<Input value={isoConfig.ip_address} onChange={e => setIsoConfig(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.10.10.5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Label>Gateway</Label>
|
||||||
|
<Input value={isoConfig.gateway} onChange={e => setIsoConfig(p => ({ ...p, gateway: e.target.value }))} placeholder="10.10.10.1" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="text-xs text-muted-foreground">{t('wizard.iso_ip_hint')}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step 4: Configuration — LXC */}
|
{/* Step 4: Configuration — LXC */}
|
||||||
{step === 'config' && kind === 'lxc' && (
|
{step === 'config' && kind === 'lxc' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -751,6 +1107,34 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Step 5: Confirm — ISO */}
|
||||||
|
{step === 'confirm' && kind === 'iso' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-6 space-y-3">
|
||||||
|
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
|
||||||
|
<Row label={t('common.type')} value="VM (QEMU) · ISO" />
|
||||||
|
<Row label={t('wizard.target_node')} value={selectedNode} />
|
||||||
|
<Row label={t('wizard.iso_image')} value={isoConfig.iso.split('/').pop() || isoConfig.iso} />
|
||||||
|
{isoConfig.extra_iso && <Row label={t('wizard.iso_extra')} value={isoConfig.extra_iso.split('/').pop() || isoConfig.extra_iso} />}
|
||||||
|
<Row label={t('common.name')} value={isoConfig.name} />
|
||||||
|
<Row label={t('common.vcpu')} value={String(isoConfig.cores)} />
|
||||||
|
<Row label={t('wizard.memory_mb')} value={`${isoConfig.memory} MB`} />
|
||||||
|
<Row label={t('wizard.disk_gb')} value={`${isoConfig.disk} GB`} />
|
||||||
|
<Row label={t('wizard.storage')} value={isoConfig.storage} />
|
||||||
|
<Row label={t('wizard.disk_bus')} value={isoConfig.disk_bus} />
|
||||||
|
<Row label="BIOS" value={isoConfig.bios === 'ovmf' ? 'OVMF (UEFI)' : 'SeaBIOS'} />
|
||||||
|
{isoConfig.tpm && <Row label={t('wizard.tpm')} value={t('common.yes')} />}
|
||||||
|
<Row label={t('wizard.bridge')} value={`${isoConfig.bridge} · ${isoConfig.net_model}`} />
|
||||||
|
{isoConfig.ipam_network_id && <Row label={t('common.ipam')} value={networks.find(n => n.id === isoConfig.ipam_network_id)?.name || 'Auto'} />}
|
||||||
|
{isoConfig.ip_address && <Row label={t('common.primary_ip')} value={isoConfig.ip_address} />}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<p className="text-sm text-muted-foreground">{t('wizard.iso_next_steps')}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Step 5: Confirm — LXC */}
|
{/* Step 5: Confirm — LXC */}
|
||||||
{step === 'confirm' && kind === 'lxc' && (
|
{step === 'confirm' && kind === 'lxc' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -775,14 +1159,24 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Загрузка ISO прямо из мастера: файл появится в списке после фоновой задачи */}
|
||||||
|
{selectedServer && (
|
||||||
|
<DownloadIsoDialog
|
||||||
|
open={isoDownloadOpen}
|
||||||
|
onClose={() => { setIsoDownloadOpen(false); isoList.refetch(); }}
|
||||||
|
serverId={selectedServer.id}
|
||||||
|
node={selectedNode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<div className="flex items-center justify-between border-t pt-4">
|
<div className="flex items-center justify-between border-t pt-4">
|
||||||
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
|
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
|
||||||
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
|
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
|
||||||
</Button>
|
</Button>
|
||||||
{step === 'confirm' ? (
|
{step === 'confirm' ? (
|
||||||
<Button onClick={handleDeploy} disabled={deploy.isPending || deployLXC.isPending}>
|
<Button onClick={handleDeploy} disabled={deploy.isPending || deployLXC.isPending || createIsoVM.isPending}>
|
||||||
{(deploy.isPending || deployLXC.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{(deploy.isPending || deployLXC.isPending || createIsoVM.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{t('wizard.deploy')}
|
{t('wizard.deploy')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ export interface ImageDownloadRequest {
|
|||||||
node: string;
|
node: string;
|
||||||
storage: string;
|
storage: string;
|
||||||
source_id?: string;
|
source_id?: string;
|
||||||
kind?: 'qcow2' | 'vztmpl';
|
kind?: 'qcow2' | 'vztmpl' | 'iso';
|
||||||
url?: string;
|
url?: string;
|
||||||
template?: string;
|
template?: string;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
|
|||||||
@@ -845,3 +845,39 @@ export function useDetachIso(serverId: number, vmid: number, node: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Пустая ВМ под установку с ISO ====================
|
||||||
|
export interface IsoVMCreateRequest {
|
||||||
|
server_id: number;
|
||||||
|
node: string;
|
||||||
|
name: string;
|
||||||
|
iso: string;
|
||||||
|
vmid?: number;
|
||||||
|
cores: number;
|
||||||
|
memory: number;
|
||||||
|
disk: number;
|
||||||
|
storage: string;
|
||||||
|
bridge: string;
|
||||||
|
ostype: string;
|
||||||
|
disk_bus: 'scsi' | 'sata' | 'virtio';
|
||||||
|
net_model: 'virtio' | 'e1000' | 'rtl8139' | 'vmxnet3';
|
||||||
|
bios: 'seabios' | 'ovmf';
|
||||||
|
tpm: boolean;
|
||||||
|
extra_iso?: string;
|
||||||
|
ip_address?: string;
|
||||||
|
gateway?: string;
|
||||||
|
ipam_network_id?: number;
|
||||||
|
owner_id?: number;
|
||||||
|
start_after_create: boolean;
|
||||||
|
onboot: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateIsoVM() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation<{ task_id: number; status: string; name: string }, Error, IsoVMCreateRequest>({
|
||||||
|
mutationFn: (body) => apiClient.post('/proxmox/api/vm/create-from-iso', body),
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: vmKeys.all });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -342,7 +342,8 @@
|
|||||||
"placeholder_snapshot_name": "e.g. before-update",
|
"placeholder_snapshot_name": "e.g. before-update",
|
||||||
"placeholder_root_pam": "root@pam",
|
"placeholder_root_pam": "root@pam",
|
||||||
"placeholder_disk": "scsi0",
|
"placeholder_disk": "scsi0",
|
||||||
"placeholder_notes": "Описание / заметки об инстансе..."
|
"placeholder_notes": "Описание / заметки об инстансе...",
|
||||||
|
"ipam": "IPAM"
|
||||||
},
|
},
|
||||||
"snap_archive": {
|
"snap_archive": {
|
||||||
"title": "Snapshot archive",
|
"title": "Snapshot archive",
|
||||||
@@ -585,7 +586,29 @@
|
|||||||
"lxc_password_placeholder": "Container root password",
|
"lxc_password_placeholder": "Container root password",
|
||||||
"swap_mb": "Swap (MB)",
|
"swap_mb": "Swap (MB)",
|
||||||
"unprivileged": "Unprivileged container",
|
"unprivileged": "Unprivileged container",
|
||||||
"lxc_template_label": "CT Template"
|
"lxc_template_label": "CT Template",
|
||||||
|
"type_iso": "VM from ISO",
|
||||||
|
"type_iso_desc": "Blank QEMU/KVM VM for an OS install from an ISO",
|
||||||
|
"select_iso": "Select installation ISO",
|
||||||
|
"select_node_first": "Select a node",
|
||||||
|
"iso_node_hint": "The ISO list comes from this node's storages",
|
||||||
|
"iso_image": "ISO image",
|
||||||
|
"iso_select_placeholder": "Select ISO",
|
||||||
|
"no_isos": "No ISO images on this node. Upload one to a storage with the ISO content type first.",
|
||||||
|
"iso_extra": "Second ISO (drivers)",
|
||||||
|
"iso_extra_none": "Not used",
|
||||||
|
"iso_extra_hint": "Mounted as a second CD-ROM — for example virtio-win when installing Windows",
|
||||||
|
"iso_hardware": "Hardware",
|
||||||
|
"disk_bus": "Disk bus",
|
||||||
|
"disk_bus_windows_hint": "The Windows installer will not see a VirtIO disk without drivers — pick SATA or attach virtio-win as the second ISO.",
|
||||||
|
"net_model": "Network card",
|
||||||
|
"tpm": "TPM 2.0",
|
||||||
|
"os_type": "Guest OS",
|
||||||
|
"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_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_download": "Download an ISO to this node",
|
||||||
|
"iso_refresh": "Refresh the ISO list"
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"title": "Servers",
|
"title": "Servers",
|
||||||
@@ -1519,4 +1542,4 @@
|
|||||||
"terminal_closed": "Terminal connection closed",
|
"terminal_closed": "Terminal connection closed",
|
||||||
"terminal_error": "Terminal connection error"
|
"terminal_error": "Terminal connection error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,7 +342,8 @@
|
|||||||
"placeholder_snapshot_name": "например перед-обновлением",
|
"placeholder_snapshot_name": "например перед-обновлением",
|
||||||
"placeholder_root_pam": "root@pam",
|
"placeholder_root_pam": "root@pam",
|
||||||
"placeholder_disk": "scsi0",
|
"placeholder_disk": "scsi0",
|
||||||
"placeholder_notes": "Описание / заметки об инстансе..."
|
"placeholder_notes": "Описание / заметки об инстансе...",
|
||||||
|
"ipam": "IPAM"
|
||||||
},
|
},
|
||||||
"snap_archive": {
|
"snap_archive": {
|
||||||
"title": "Архив снапшотов",
|
"title": "Архив снапшотов",
|
||||||
@@ -585,7 +586,29 @@
|
|||||||
"lxc_password_placeholder": "Пароль root контейнера",
|
"lxc_password_placeholder": "Пароль root контейнера",
|
||||||
"swap_mb": "Своп (МБ)",
|
"swap_mb": "Своп (МБ)",
|
||||||
"unprivileged": "Непривилегированный контейнер",
|
"unprivileged": "Непривилегированный контейнер",
|
||||||
"lxc_template_label": "CT Шаблон"
|
"lxc_template_label": "CT Шаблон",
|
||||||
|
"type_iso": "ВМ из ISO",
|
||||||
|
"type_iso_desc": "Пустая QEMU/KVM ВМ под установку ОС с ISO",
|
||||||
|
"select_iso": "Выберите установочный ISO",
|
||||||
|
"select_node_first": "Выберите ноду",
|
||||||
|
"iso_node_hint": "Список ISO берётся с хранилищ этой ноды",
|
||||||
|
"iso_image": "ISO образ",
|
||||||
|
"iso_select_placeholder": "Выберите ISO",
|
||||||
|
"no_isos": "На ноде нет ISO образов. Сначала загрузите образ в хранилище с типом контента ISO.",
|
||||||
|
"iso_extra": "Второй ISO (драйверы)",
|
||||||
|
"iso_extra_none": "Не использовать",
|
||||||
|
"iso_extra_hint": "Подключается вторым CD-ROM — например virtio-win при установке Windows",
|
||||||
|
"iso_hardware": "Оборудование",
|
||||||
|
"disk_bus": "Шина диска",
|
||||||
|
"disk_bus_windows_hint": "Установщик Windows не увидит VirtIO-диск без драйверов — выберите SATA или подключите virtio-win вторым ISO.",
|
||||||
|
"net_model": "Сетевая карта",
|
||||||
|
"tpm": "TPM 2.0",
|
||||||
|
"os_type": "Гостевая ОС",
|
||||||
|
"os_type_hint": "Выбор ОС подставляет BIOS, шину диска и сетевую карту",
|
||||||
|
"iso_ip_hint": "IP только резервируется в IPAM — cloud-init здесь нет, адрес нужно ввести вручную в установщике.",
|
||||||
|
"iso_next_steps": "ВМ загрузится с ISO. Откройте консоль и пройдите установку; после неё отключите ISO и поставьте диск первым в порядке загрузки.",
|
||||||
|
"iso_download": "Загрузить ISO на эту ноду",
|
||||||
|
"iso_refresh": "Обновить список ISO"
|
||||||
},
|
},
|
||||||
"nodes": {
|
"nodes": {
|
||||||
"title": "Серверы",
|
"title": "Серверы",
|
||||||
@@ -1633,4 +1656,4 @@
|
|||||||
"access_manage": "Управление доступом PVE"
|
"access_manage": "Управление доступом PVE"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface DeployTask {
|
|||||||
node: string | null;
|
node: string | null;
|
||||||
error_message: string | null;
|
error_message: string | null;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
kind?: 'deploy' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template' | 'migrate' | 'remote_migrate';
|
kind?: 'deploy' | 'vm_iso' | 'reinstall' | 'clone' | 'change_password' | 'image_download' | 'image_template' | 'migrate' | 'remote_migrate';
|
||||||
server_id?: number | null;
|
server_id?: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,7 +441,7 @@ export interface OSTemplate {
|
|||||||
|
|
||||||
// ==================== Image Catalog Types ====================
|
// ==================== Image Catalog Types ====================
|
||||||
|
|
||||||
export type ImageKind = 'qcow2' | 'vztmpl';
|
export type ImageKind = 'qcow2' | 'vztmpl' | 'iso';
|
||||||
export type ImageArch = 'amd64' | 'arm64';
|
export type ImageArch = 'amd64' | 'arm64';
|
||||||
|
|
||||||
export interface CatalogImage {
|
export interface CatalogImage {
|
||||||
|
|||||||
Reference in New Issue
Block a user