feat(instances): create blank VMs for ISO installs and download ISOs
Some checks failed
Backend Tests / pytest (Python 3.12) (push) Has been cancelled
Deploy to Production / Trigger production update (push) Has been cancelled

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:
markmorado
2026-07-24 05:00:51 +05:00
parent 6a4da4f80b
commit ebc692a324
17 changed files with 1338 additions and 35 deletions

View File

@@ -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)

View File

@@ -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}'

View File

@@ -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'

View 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)

View File

@@ -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}",

View File

@@ -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:

View 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>
);
}

View 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>
);
}

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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>
) : ( ) : (

View File

@@ -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;

View File

@@ -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 });
},
});
}

View File

@@ -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"
} }
} }

View File

@@ -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"
} }
} }
} }

View File

@@ -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;
} }

View File

@@ -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 {