feat: LXC container creation from CT templates
- Add LXC deploy API endpoints: GET /proxmox/api/lxc-templates/{id},
GET /proxmox/api/lxc-storages/{id}, POST /proxmox/api/lxc/deploy
- Add ProxmoxClient.get_lxc_storage_templates() and get_node_storages()
- Fix duplicate create_lxc_container() — removed dead copy, updated
canonical with nameserver/searchdomain params
- Fix wait_for_task(): accept WARNINGS:N exitstatus as success
- Add IPAM IP release on deploy failure
- Add frontend hooks: useLXCTemplates, useLXCStorages, useDeployLXC
with 5-min staleTime caching
- Add LXC wizard steps to CreateInstanceWizard (type, template, config)
- Add LXC i18n keys to en.json and ru.json
- Add LXCTemplate and LXCDeployRequest TypeScript interfaces
This commit is contained in:
@@ -7,6 +7,7 @@ from .tasks import router as tasks_router
|
||||
from .backup import router as backup_router
|
||||
from .cluster import router as cluster_router
|
||||
from .networks import router as networks_router
|
||||
from .lxc import router as lxc_router
|
||||
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||
|
||||
router = APIRouter()
|
||||
@@ -18,3 +19,4 @@ router.include_router(tasks_router)
|
||||
router.include_router(backup_router)
|
||||
router.include_router(cluster_router)
|
||||
router.include_router(networks_router)
|
||||
router.include_router(lxc_router)
|
||||
|
||||
461
backend/app/api/proxmox/lxc.py
Normal file
461
backend/app/api/proxmox/lxc.py
Normal file
@@ -0,0 +1,461 @@
|
||||
"""
|
||||
API endpoints for creating LXC containers from Proxmox CT templates (vztmpl files).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timezone
|
||||
from typing import List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
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, VMInstance
|
||||
from ...proxmox_client import ProxmoxClient
|
||||
from ...websocket_manager import broadcast_task_update
|
||||
from ._helpers import _get_proxmox_client, get_next_vmid, save_vm_instance
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_lxc_deploy_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="lxc_deploy")
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# ── Pydantic schemas ─────────────────────────────────────────────────────────
|
||||
|
||||
class LXCDeployRequest(BaseModel):
|
||||
server_id: int
|
||||
ostemplate: str # volid, e.g. "local:vztmpl/debian-13-standard_13.1-2_amd64.tar.zst"
|
||||
node: Optional[str] = None
|
||||
name: str # hostname / display name
|
||||
vmid: Optional[int] = None
|
||||
cores: int = 1
|
||||
memory: int = 512 # МБ
|
||||
swap: int = 512 # МБ
|
||||
disk: int = 8 # ГБ
|
||||
storage: str = "local-lvm"
|
||||
bridge: str = "vmbr0"
|
||||
ip_address: Optional[str] = None
|
||||
gateway: Optional[str] = None
|
||||
ipam_network_id: Optional[int] = None
|
||||
ipam_pool_id: Optional[int] = None
|
||||
password: Optional[str] = None
|
||||
ssh_keys: Optional[str] = None
|
||||
nameserver: Optional[str] = None
|
||||
searchdomain: Optional[str] = None
|
||||
unprivileged: bool = True
|
||||
start_after_create: bool = True
|
||||
onboot: bool = False
|
||||
|
||||
|
||||
class LXCDeployResponse(BaseModel):
|
||||
task_id: int
|
||||
status: str
|
||||
name: str
|
||||
|
||||
|
||||
class LXCTemplateItem(BaseModel):
|
||||
volid: str
|
||||
storage: str
|
||||
node: str
|
||||
name: str
|
||||
size: int
|
||||
|
||||
|
||||
# ── 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_lxc_deploy(task_id: int, req: LXCDeployRequest, user_id: int, username: str):
|
||||
"""Blocking LXC deploy — runs in thread pool."""
|
||||
db = SessionLocal()
|
||||
server = None
|
||||
deploy_node = 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
|
||||
|
||||
# Connect
|
||||
_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
|
||||
|
||||
# Determine node
|
||||
deploy_node = req.node
|
||||
if not deploy_node:
|
||||
nodes = client.get_nodes()
|
||||
if not nodes:
|
||||
_update_task(task_id, "failed", "Нет доступных нод", 15, error="Не найдено нод Proxmox")
|
||||
return
|
||||
# Try to find a node that has the template storage
|
||||
for n in nodes:
|
||||
try:
|
||||
storages = client.proxmox.nodes(n["node"]).storage.get()
|
||||
for s in storages:
|
||||
if "vztmpl" in s.get("content", ""):
|
||||
deploy_node = n["node"]
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if deploy_node:
|
||||
break
|
||||
if not deploy_node:
|
||||
deploy_node = nodes[0]["node"]
|
||||
|
||||
# Allocate VMID
|
||||
_update_task(task_id, "running", "Генерация VMID...", 20)
|
||||
if req.vmid:
|
||||
new_vmid = req.vmid
|
||||
else:
|
||||
new_vmid = get_next_vmid(db, server.id)
|
||||
|
||||
# IPAM
|
||||
_update_task(task_id, "running", "Выделение IP-адреса...", 25)
|
||||
allocated_ip = req.ip_address
|
||||
gateway = req.gateway
|
||||
network_mask = "24"
|
||||
ipam_allocation = None
|
||||
|
||||
if req.ipam_network_id and not req.ip_address:
|
||||
ipam = IPAMService(db)
|
||||
ipam_allocation, error = ipam.auto_allocate_ip(
|
||||
network_id=req.ipam_network_id,
|
||||
pool_id=req.ipam_pool_id,
|
||||
resource_type="ct",
|
||||
resource_name=req.name,
|
||||
proxmox_server_id=server.id,
|
||||
proxmox_vmid=new_vmid,
|
||||
proxmox_node=deploy_node,
|
||||
allocated_by=username,
|
||||
notes=f"Auto-allocated for LXC {req.name}",
|
||||
)
|
||||
if not error:
|
||||
allocated_ip = ipam_allocation.ip_address
|
||||
if not gateway:
|
||||
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == req.ipam_network_id).first()
|
||||
if net and net.gateway:
|
||||
gateway = net.gateway
|
||||
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="ct",
|
||||
resource_name=req.name,
|
||||
proxmox_server_id=server.id,
|
||||
proxmox_vmid=new_vmid,
|
||||
proxmox_node=deploy_node,
|
||||
allocated_by=username,
|
||||
notes=f"Manually assigned for LXC {req.name}",
|
||||
)
|
||||
|
||||
if allocated_ip and 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
|
||||
if network_id:
|
||||
net = db.query(IPAMNetwork).filter(IPAMNetwork.id == network_id).first()
|
||||
if net and net.network and "/" in net.network:
|
||||
network_mask = net.network.split("/")[1]
|
||||
if not gateway and net and net.gateway:
|
||||
gateway = net.gateway
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not get network info from IPAM: {e}")
|
||||
|
||||
# Build ip_config string for LXC net0
|
||||
if allocated_ip:
|
||||
ip_config = f"ip={allocated_ip}/{network_mask},gw={gateway}" if gateway else f"ip={allocated_ip}/{network_mask}"
|
||||
else:
|
||||
ip_config = "ip=dhcp"
|
||||
|
||||
# Create LXC
|
||||
_update_task(task_id, "running", f"Создание LXC контейнера VMID {new_vmid}...", 35,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
net0_str = f"name=eth0,bridge={req.bridge},{ip_config},firewall=1"
|
||||
upid = client.create_lxc_container(
|
||||
node=deploy_node,
|
||||
vmid=new_vmid,
|
||||
ostemplate=req.ostemplate,
|
||||
hostname=req.name,
|
||||
cores=req.cores,
|
||||
memory=req.memory,
|
||||
swap=req.swap,
|
||||
storage=req.storage,
|
||||
rootfs_size=req.disk,
|
||||
net0=net0_str,
|
||||
nameserver=req.nameserver,
|
||||
searchdomain=req.searchdomain,
|
||||
password=req.password,
|
||||
ssh_public_keys=req.ssh_keys,
|
||||
unprivileged=req.unprivileged,
|
||||
start_after_create=False, # we start manually after config
|
||||
onboot=req.onboot,
|
||||
description=f"Created from {req.ostemplate} by {username}",
|
||||
)
|
||||
if not upid:
|
||||
_update_task(task_id, "failed", "Ошибка создания контейнера", 35,
|
||||
error="Proxmox не вернул UPID задачи создания контейнера")
|
||||
return
|
||||
|
||||
# Wait for creation task
|
||||
_update_task(task_id, "running", "Ожидание завершения создания...", 50,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
success = client.wait_for_task(deploy_node, upid, timeout=300)
|
||||
if not success:
|
||||
_update_task(task_id, "failed", "Таймаут создания контейнера", 50,
|
||||
error="Создание контейнера заняло слишком долго (>5 мин)")
|
||||
return
|
||||
|
||||
_update_task(task_id, "running", "Контейнер создан", 70, vmid=new_vmid, node=deploy_node)
|
||||
|
||||
# Start if requested
|
||||
if req.start_after_create:
|
||||
_update_task(task_id, "running", "Запуск контейнера...", 85,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
client.start_container(deploy_node, new_vmid)
|
||||
|
||||
# Save to DB
|
||||
_update_task(task_id, "running", "Сохранение в базе данных...", 95,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
save_vm_instance(
|
||||
db=db,
|
||||
server_id=server.id,
|
||||
vmid=new_vmid,
|
||||
node=deploy_node,
|
||||
vm_type="lxc",
|
||||
name=req.name,
|
||||
cores=req.cores,
|
||||
memory=req.memory,
|
||||
disk_size=req.disk,
|
||||
ip_address=allocated_ip,
|
||||
ip_prefix=int(network_mask),
|
||||
gateway=gateway,
|
||||
cloud_init_user=None,
|
||||
cloud_init_password=None,
|
||||
ssh_keys=req.ssh_keys,
|
||||
template_id=None,
|
||||
template_name=req.ostemplate,
|
||||
description=f"Created from CT template {req.ostemplate}",
|
||||
owner_id=user_id,
|
||||
)
|
||||
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db,
|
||||
action="create",
|
||||
resource_type="ct",
|
||||
resource_id=new_vmid,
|
||||
username=username,
|
||||
resource_name=req.name,
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
node_name=deploy_node,
|
||||
details={
|
||||
"ostemplate": req.ostemplate,
|
||||
"cores": req.cores,
|
||||
"memory": req.memory,
|
||||
"disk": req.disk,
|
||||
"ip_address": allocated_ip,
|
||||
},
|
||||
success=True,
|
||||
)
|
||||
|
||||
_update_task(task_id, "completed", "Контейнер успешно создан!", 100,
|
||||
vmid=new_vmid, node=deploy_node)
|
||||
logger.info(f"[LXC DEPLOY] Task #{task_id}: {req.name} (VMID {new_vmid}) deployed by {username}")
|
||||
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
logger.error(f"[LXC DEPLOY] Task #{task_id} failed: {err}")
|
||||
# Release IPAM allocation on failure
|
||||
try:
|
||||
if ipam_allocation is not None:
|
||||
ipam = IPAMService(db)
|
||||
ipam.release_ip(ipam_allocation.id)
|
||||
logger.info(f"[LXC DEPLOY] Task #{task_id}: Released IPAM IP {ipam_allocation.ip_address} on failure")
|
||||
except Exception as ipam_err:
|
||||
logger.warning(f"[LXC DEPLOY] Task #{task_id}: Could not release IPAM IP: {ipam_err}")
|
||||
try:
|
||||
if server:
|
||||
LoggingService.log_proxmox_action(
|
||||
db=db,
|
||||
action="create",
|
||||
resource_type="ct",
|
||||
resource_id=req.vmid,
|
||||
username=username,
|
||||
resource_name=req.name,
|
||||
server_id=server.id,
|
||||
server_name=server.name,
|
||||
node_name=deploy_node,
|
||||
details={"ostemplate": req.ostemplate},
|
||||
success=False,
|
||||
error_message=err,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
_update_task(task_id, "failed", f"Ошибка: {err[:150]}", 0, error=err)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ── Endpoints ────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/api/lxc-templates/{server_id}")
|
||||
def list_lxc_templates(
|
||||
server_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vms.view")),
|
||||
):
|
||||
"""
|
||||
Список LXC CT-шаблонов (vztmpl) на хранилищах выбранного Proxmox сервера.
|
||||
"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||||
|
||||
node_filter = None
|
||||
if server.hostname and server.hostname != server.ip_address:
|
||||
node_filter = server.hostname
|
||||
|
||||
templates = client.get_lxc_storage_templates(node=node_filter)
|
||||
return JSONResponse(content={"server_id": server_id, "templates": templates})
|
||||
|
||||
|
||||
@router.get("/api/lxc-storages/{server_id}")
|
||||
def list_lxc_storages(
|
||||
server_id: int,
|
||||
node: Optional[str] = None,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vms.view")),
|
||||
):
|
||||
"""
|
||||
Список хранилищ ноды, пригодных для rootfs LXC контейнера.
|
||||
"""
|
||||
server = db.query(ProxmoxServer).filter(ProxmoxServer.id == server_id).first()
|
||||
if not server:
|
||||
raise HTTPException(status_code=404, detail="Proxmox сервер не найден")
|
||||
|
||||
client = _get_proxmox_client(server)
|
||||
if not client.is_connected():
|
||||
raise HTTPException(status_code=503, detail="Не удалось подключиться к Proxmox серверу")
|
||||
|
||||
target_node = node or server.hostname or None
|
||||
if not target_node:
|
||||
nodes = client.get_nodes()
|
||||
target_node = nodes[0]["node"] if nodes else None
|
||||
if not target_node:
|
||||
raise HTTPException(status_code=400, detail="Не удалось определить ноду")
|
||||
|
||||
storages = client.get_node_storages(target_node)
|
||||
result = []
|
||||
for s in storages:
|
||||
content = s.get("content", "")
|
||||
if "rootdir" in content or "images" in content:
|
||||
result.append({
|
||||
"storage": s.get("storage"),
|
||||
"type": s.get("type"),
|
||||
"avail": s.get("avail"),
|
||||
"total": s.get("total"),
|
||||
"used": s.get("used"),
|
||||
"active": s.get("active", 0),
|
||||
})
|
||||
return JSONResponse(content={"storages": result})
|
||||
|
||||
|
||||
@router.post("/api/lxc/deploy", response_model=LXCDeployResponse, status_code=202)
|
||||
async def deploy_lxc_from_template(
|
||||
req: LXCDeployRequest,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(PermissionChecker("vms.create")),
|
||||
):
|
||||
"""
|
||||
Асинхронное создание LXC контейнера из CT-шаблона.
|
||||
Возвращает 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.ostemplate:
|
||||
raise HTTPException(status_code=400, detail="Необходимо указать ostemplate")
|
||||
if not req.name or len(req.name) < 1:
|
||||
raise HTTPException(status_code=400, detail="Укажите имя контейнера")
|
||||
|
||||
# Create task record
|
||||
task = DeployTask(
|
||||
status="pending",
|
||||
step="В очереди...",
|
||||
progress=0,
|
||||
name=req.name,
|
||||
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(
|
||||
_lxc_deploy_executor,
|
||||
_do_lxc_deploy,
|
||||
task_id,
|
||||
req,
|
||||
current_user.id,
|
||||
current_user.username,
|
||||
)
|
||||
|
||||
return LXCDeployResponse(task_id=task_id, status="pending", name=req.name)
|
||||
@@ -520,6 +520,68 @@ class ProxmoxClient:
|
||||
logger.error(f"Ошибка получения списка ISO для ноды {node}: {e}")
|
||||
return result
|
||||
|
||||
def get_lxc_storage_templates(self, node: Optional[str] = None) -> List[Dict]:
|
||||
"""
|
||||
Получить список LXC CT-шаблонов из хранилищ Proxmox.
|
||||
Ищет файлы типа vzdump/vztmpl на всех нодах (или на указанной).
|
||||
Возвращает список объектов с полями: volid, storage, node, name, size.
|
||||
"""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
result: List[Dict] = []
|
||||
try:
|
||||
if node:
|
||||
nodes_list = [{'node': node}]
|
||||
else:
|
||||
nodes_list = self.get_nodes()
|
||||
|
||||
seen_volids: set = set()
|
||||
for n in nodes_list:
|
||||
node_name = n.get('node')
|
||||
try:
|
||||
storages = self.proxmox.nodes(node_name).storage.get()
|
||||
for s in storages:
|
||||
content = s.get('content', '')
|
||||
if 'vztmpl' not in content:
|
||||
continue
|
||||
storage_id = s.get('storage')
|
||||
try:
|
||||
items = self.proxmox.nodes(node_name).storage(storage_id).content.get(content='vztmpl')
|
||||
for it in items:
|
||||
volid = it.get('volid', '')
|
||||
if volid in seen_volids:
|
||||
continue
|
||||
seen_volids.add(volid)
|
||||
filename = volid.split('/')[-1] if '/' in volid else volid
|
||||
result.append({
|
||||
'volid': volid,
|
||||
'storage': storage_id,
|
||||
'node': node_name,
|
||||
'name': filename,
|
||||
'size': it.get('size', 0),
|
||||
})
|
||||
except Exception as e:
|
||||
logger.debug(f"Не удалось получить CT-шаблоны из {storage_id} на {node_name}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения хранилищ ноды {node_name}: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения CT-шаблонов {self.host}: {e}")
|
||||
return result
|
||||
|
||||
def get_node_storages(self, node: str, content_type: Optional[str] = None) -> List[Dict]:
|
||||
"""Получить список хранилищ ноды. Опционально фильтровать по типу содержимого."""
|
||||
if not self.proxmox:
|
||||
return []
|
||||
try:
|
||||
params = {}
|
||||
if content_type:
|
||||
params['content'] = content_type
|
||||
storages = self.proxmox.nodes(node).storage.get(**params)
|
||||
return storages or []
|
||||
except Exception as e:
|
||||
logger.error(f"Ошибка получения хранилищ ноды {node}: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def force_stop_vm(self, node: str, vmid: int) -> bool:
|
||||
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
|
||||
@@ -1626,7 +1688,11 @@ class ProxmoxClient:
|
||||
if status:
|
||||
if status.get('status') == 'stopped':
|
||||
# Проверяем exitstatus
|
||||
return status.get('exitstatus') == 'OK'
|
||||
exit_status = status.get('exitstatus')
|
||||
if exit_status != 'OK':
|
||||
logger.error(f"Proxmox task {upid} failed with exitstatus={exit_status!r}")
|
||||
# 'OK' — success; 'WARNINGS: N' — success with warnings (container still created)
|
||||
return exit_status == 'OK' or (isinstance(exit_status, str) and exit_status.startswith('WARNINGS:'))
|
||||
time_module.sleep(2)
|
||||
|
||||
logger.warning(f"Таймаут ожидания задачи {upid}")
|
||||
@@ -2300,7 +2366,9 @@ class ProxmoxClient:
|
||||
start_after_create: bool = False,
|
||||
onboot: bool = False,
|
||||
description: str = None,
|
||||
features: str = None
|
||||
features: str = None,
|
||||
nameserver: str = None,
|
||||
searchdomain: str = None,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Создать новый LXC контейнер
|
||||
@@ -2364,6 +2432,12 @@ class ProxmoxClient:
|
||||
if features:
|
||||
params['features'] = features
|
||||
|
||||
if nameserver:
|
||||
params['nameserver'] = nameserver
|
||||
|
||||
if searchdomain:
|
||||
params['searchdomain'] = searchdomain
|
||||
|
||||
result = self.proxmox.nodes(node).lxc.post(**params)
|
||||
logger.info(f"Запущено создание LXC контейнера {vmid} ({hostname}) на {node}")
|
||||
return result
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor } from 'lucide-react';
|
||||
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -12,12 +12,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
||||
import { useServers } from '@/hooks/use-nodes';
|
||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
||||
import type { VMDeployRequest, OSTemplate, ProxmoxServer } from '@/types';
|
||||
import type { VMDeployRequest, OSTemplate, ProxmoxServer, LXCTemplate } from '@/types';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const STEPS = ['server', 'template', 'config', 'confirm'] as const;
|
||||
type InstanceKind = 'vm' | 'lxc';
|
||||
const STEPS = ['server', 'type', 'template', 'config', 'confirm'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
|
||||
@@ -29,8 +31,11 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
|
||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||
|
||||
// State
|
||||
// ── Shared state ──────────────────────────────────────────────────────────
|
||||
const [kind, setKind] = useState<InstanceKind>('lxc');
|
||||
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
|
||||
|
||||
// ── VM state ──────────────────────────────────────────────────────────────
|
||||
const [selectedNode, setSelectedNode] = useState('');
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
|
||||
const [config, setConfig] = useState<{
|
||||
@@ -63,11 +68,34 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
ssh_keys: '',
|
||||
});
|
||||
|
||||
// ── LXC state ─────────────────────────────────────────────────────────────
|
||||
const [selectedLXCTemplate, setSelectedLXCTemplate] = useState<LXCTemplate | null>(null);
|
||||
const [lxcConfig, setLxcConfig] = useState({
|
||||
name: '',
|
||||
cores: 1,
|
||||
memory: 512,
|
||||
swap: 512,
|
||||
disk: 8,
|
||||
storage: 'local-lvm',
|
||||
bridge: 'vmbr0',
|
||||
ipam_network_id: null as number | null,
|
||||
ip_address: '',
|
||||
gateway: '',
|
||||
password: '',
|
||||
ssh_keys: '',
|
||||
nameserver: '',
|
||||
unprivileged: true,
|
||||
start_after_create: true,
|
||||
onboot: false,
|
||||
});
|
||||
|
||||
// Queries
|
||||
const { data: servers = [] } = useServers();
|
||||
const { data: groups = [] } = useTemplateGroups();
|
||||
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
|
||||
const { data: networks = [] } = useIPAMNetworks();
|
||||
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
|
||||
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id);
|
||||
|
||||
const deploy = useMutation({
|
||||
mutationFn: (data: VMDeployRequest) =>
|
||||
@@ -89,6 +117,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const deployLXC = useDeployLXC();
|
||||
|
||||
const next = () => {
|
||||
const i = STEPS.indexOf(step);
|
||||
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
|
||||
@@ -100,12 +130,56 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
|
||||
const canNext = () => {
|
||||
if (step === 'server') return !!selectedServer;
|
||||
if (step === 'template') return !!selectedTemplate;
|
||||
if (step === 'config') return config.name.length >= 2;
|
||||
if (step === 'type') return !!kind;
|
||||
if (step === 'template') return kind === 'vm' ? !!selectedTemplate : !!selectedLXCTemplate;
|
||||
if (step === 'config') return kind === 'vm' ? config.name.length >= 2 : lxcConfig.name.length >= 2;
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleDeploy = () => {
|
||||
if (kind === 'lxc') {
|
||||
if (!selectedLXCTemplate || !selectedServer) return;
|
||||
deployLXC.mutate(
|
||||
{
|
||||
server_id: selectedServer.id,
|
||||
ostemplate: selectedLXCTemplate.volid,
|
||||
node: selectedLXCTemplate.node || undefined,
|
||||
name: lxcConfig.name,
|
||||
cores: lxcConfig.cores,
|
||||
memory: lxcConfig.memory,
|
||||
swap: lxcConfig.swap,
|
||||
disk: lxcConfig.disk,
|
||||
storage: lxcConfig.storage,
|
||||
bridge: lxcConfig.bridge,
|
||||
ip_address: lxcConfig.ip_address || undefined,
|
||||
gateway: lxcConfig.gateway || undefined,
|
||||
ipam_network_id: lxcConfig.ipam_network_id || undefined,
|
||||
password: lxcConfig.password || undefined,
|
||||
ssh_keys: lxcConfig.ssh_keys || undefined,
|
||||
nameserver: lxcConfig.nameserver || undefined,
|
||||
unprivileged: lxcConfig.unprivileged,
|
||||
start_after_create: lxcConfig.start_after_create,
|
||||
onboot: lxcConfig.onboot,
|
||||
},
|
||||
{
|
||||
onSuccess: (res) => {
|
||||
addDeployTask({
|
||||
id: res.task_id,
|
||||
name: res.name,
|
||||
status: 'pending',
|
||||
step: t('wizard.deploy_starting'),
|
||||
progress: 0,
|
||||
vmid: null,
|
||||
node: null,
|
||||
error_message: null,
|
||||
});
|
||||
toast.success(t('wizard.deploy_queued', { name: res.name }));
|
||||
navigate('/instances');
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
if (!selectedTemplate) return;
|
||||
deploy.mutate({
|
||||
template_id: selectedTemplate.id,
|
||||
@@ -124,6 +198,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
cloud_init_password: config.cloud_init_password || undefined,
|
||||
ssh_keys: config.ssh_keys || undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Apply template defaults when template selected
|
||||
@@ -164,7 +239,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
<Card
|
||||
key={srv.id}
|
||||
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedServer?.id === srv.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
|
||||
onClick={() => setSelectedServer(srv)}
|
||||
onClick={() => { setSelectedServer(srv); setSelectedLXCTemplate(null); setSelectedTemplate(null); }}
|
||||
>
|
||||
<CardContent className="flex items-center gap-3 p-4">
|
||||
<Server className="h-8 w-8 text-muted-foreground" />
|
||||
@@ -182,8 +257,43 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Template */}
|
||||
{step === 'template' && (
|
||||
{/* Step 2: Type selection */}
|
||||
{step === 'type' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.select_type')}</h3>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Card
|
||||
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${kind === 'vm' ? 'border-blue-500 bg-blue-500/5' : ''}`}
|
||||
onClick={() => setKind('vm')}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<HardDrive className="h-10 w-10 text-blue-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">{t('wizard.type_vm')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('wizard.type_vm_desc')}</p>
|
||||
</div>
|
||||
{kind === 'vm' && <CheckCircle className="ml-auto h-5 w-5 text-blue-400 shrink-0" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card
|
||||
className={`cursor-pointer transition-colors hover:border-green-500/50 ${kind === 'lxc' ? 'border-green-500 bg-green-500/5' : ''}`}
|
||||
onClick={() => setKind('lxc')}
|
||||
>
|
||||
<CardContent className="flex items-center gap-4 p-5">
|
||||
<Container className="h-10 w-10 text-green-400 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold">{t('wizard.type_lxc')}</p>
|
||||
<p className="text-sm text-muted-foreground">{t('wizard.type_lxc_desc')}</p>
|
||||
</div>
|
||||
{kind === 'lxc' && <CheckCircle className="ml-auto h-5 w-5 text-green-400 shrink-0" />}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Template — VM */}
|
||||
{step === 'template' && kind === 'vm' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
|
||||
{groups.map(group => {
|
||||
@@ -224,8 +334,50 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 3: Configuration */}
|
||||
{step === 'config' && (
|
||||
{/* Step 3: Template — LXC */}
|
||||
{step === 'template' && kind === 'lxc' && (
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.select_lxc_template')}</h3>
|
||||
{lxcLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-muted-foreground gap-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t('common.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!lxcLoading && lxcTemplates.length === 0 && (
|
||||
<p className="text-center text-muted-foreground py-8">{t('wizard.no_lxc_templates')}</p>
|
||||
)}
|
||||
{!lxcLoading && lxcTemplates.length > 0 && (
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{lxcTemplates.map(tpl => (
|
||||
<Card
|
||||
key={tpl.volid}
|
||||
className={`cursor-pointer transition-colors hover:border-green-500/50 ${selectedLXCTemplate?.volid === tpl.volid ? 'border-green-500 bg-green-500/5' : ''}`}
|
||||
onClick={() => setSelectedLXCTemplate(tpl)}
|
||||
>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Container className="h-5 w-5 text-green-400 shrink-0" />
|
||||
<span className="font-medium text-sm truncate">{tpl.name}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-muted-foreground font-mono">
|
||||
{tpl.storage} · {tpl.node}
|
||||
</p>
|
||||
{tpl.size > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(tpl.size / 1024 / 1024).toFixed(0)} MB
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Configuration — VM */}
|
||||
{step === 'config' && kind === 'vm' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
@@ -350,13 +502,155 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 4: Confirm */}
|
||||
{step === 'confirm' && (
|
||||
{/* Step 4: Configuration — LXC */}
|
||||
{step === 'config' && kind === 'lxc' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||
<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('wizard.hostname')}</Label>
|
||||
<Input
|
||||
value={lxcConfig.name}
|
||||
onChange={e => setLxcConfig(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder={t('common.placeholder_vm_name')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.storage')}</Label>
|
||||
<Select value={lxcConfig.storage} onValueChange={v => setLxcConfig(p => ({ ...p, storage: v }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{lxcStorages.length > 0
|
||||
? lxcStorages.map(s => <SelectItem key={s.storage} value={s.storage}>{s.storage} ({s.type})</SelectItem>)
|
||||
: <SelectItem value="local-lvm">local-lvm</SelectItem>
|
||||
}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="lxc_start_after"
|
||||
checked={lxcConfig.start_after_create}
|
||||
onChange={e => setLxcConfig(p => ({ ...p, start_after_create: e.target.checked }))}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="lxc_start_after">{t('wizard.start_after_create')}</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="lxc_unprivileged"
|
||||
checked={lxcConfig.unprivileged}
|
||||
onChange={e => setLxcConfig(p => ({ ...p, unprivileged: e.target.checked }))}
|
||||
className="rounded"
|
||||
/>
|
||||
<Label htmlFor="lxc_unprivileged">{t('wizard.unprivileged')}</Label>
|
||||
</div>
|
||||
</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={lxcConfig.cores} onChange={e => setLxcConfig(p => ({ ...p, cores: Number(e.target.value) }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.memory_mb')}</Label>
|
||||
<Input type="number" min={64} step={128} value={lxcConfig.memory} onChange={e => setLxcConfig(p => ({ ...p, memory: Number(e.target.value) }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.swap_mb')}</Label>
|
||||
<Input type="number" min={0} step={128} value={lxcConfig.swap} onChange={e => setLxcConfig(p => ({ ...p, swap: Number(e.target.value) }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('wizard.disk_gb')}</Label>
|
||||
<Input type="number" min={1} value={lxcConfig.disk} onChange={e => setLxcConfig(p => ({ ...p, disk: Number(e.target.value) }))} />
|
||||
</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={lxcConfig.bridge} onChange={e => setLxcConfig(p => ({ ...p, bridge: e.target.value }))} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>IPAM {t('wizard.network')}</Label>
|
||||
<Select value={String(lxcConfig.ipam_network_id || '')} onValueChange={v => setLxcConfig(p => ({ ...p, ipam_network_id: v ? Number(v) : null }))}>
|
||||
<SelectTrigger><SelectValue placeholder={t('wizard.manual_ip')} /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{networks.map(n => (
|
||||
<SelectItem key={n.id} value={String(n.id)}>{n.name} ({n.network})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{!lxcConfig.ipam_network_id && (
|
||||
<>
|
||||
<div>
|
||||
<Label>IP (CIDR)</Label>
|
||||
<Input value={lxcConfig.ip_address} onChange={e => setLxcConfig(p => ({ ...p, ip_address: e.target.value }))} placeholder="10.10.10.5/24 или dhcp" />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Gateway</Label>
|
||||
<Input value={lxcConfig.gateway} onChange={e => setLxcConfig(p => ({ ...p, gateway: e.target.value }))} placeholder="10.10.10.1" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div>
|
||||
<Label>DNS</Label>
|
||||
<Input value={lxcConfig.nameserver} onChange={e => setLxcConfig(p => ({ ...p, nameserver: e.target.value }))} placeholder="8.8.8.8" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Auth */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3"><CardTitle className="text-sm">{t('wizard.lxc_password')}</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div>
|
||||
<Label>{t('wizard.lxc_password')}</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={lxcConfig.password}
|
||||
onChange={e => setLxcConfig(p => ({ ...p, password: e.target.value }))}
|
||||
placeholder={t('wizard.lxc_password_placeholder')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>SSH Keys</Label>
|
||||
<textarea
|
||||
className="w-full rounded-md border bg-transparent px-3 py-2 text-sm font-mono min-h-[60px]"
|
||||
value={lxcConfig.ssh_keys}
|
||||
onChange={e => setLxcConfig(p => ({ ...p, ssh_keys: e.target.value }))}
|
||||
placeholder={t('common.placeholder_ssh_key')}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Confirm — VM */}
|
||||
{step === 'confirm' && kind === 'vm' && (
|
||||
<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)" />
|
||||
<Row label={t('wizard.template_label')} value={selectedTemplate?.name || ''} />
|
||||
<Row label={t('common.name')} value={config.name} />
|
||||
<Row label={t('common.vcpu')} value={String(config.cores)} />
|
||||
@@ -371,14 +665,38 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 5: Confirm — LXC */}
|
||||
{step === 'confirm' && kind === 'lxc' && (
|
||||
<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="LXC Container" />
|
||||
<Row label={t('wizard.lxc_template_label')} value={selectedLXCTemplate?.name || ''} />
|
||||
<Row label={t('wizard.hostname')} value={lxcConfig.name} />
|
||||
<Row label={t('common.vcpu')} value={String(lxcConfig.cores)} />
|
||||
<Row label={t('wizard.memory_mb')} value={`${lxcConfig.memory} MB`} />
|
||||
<Row label={t('wizard.swap_mb')} value={`${lxcConfig.swap} MB`} />
|
||||
<Row label={t('wizard.disk_gb')} value={`${lxcConfig.disk} GB`} />
|
||||
<Row label={t('wizard.storage')} value={lxcConfig.storage} />
|
||||
<Row label={t('wizard.bridge')} value={lxcConfig.bridge} />
|
||||
{lxcConfig.ipam_network_id && <Row label={t('common.ipam')} value={networks.find(n => n.id === lxcConfig.ipam_network_id)?.name || 'Auto'} />}
|
||||
{lxcConfig.ip_address && <Row label={t('common.primary_ip')} value={lxcConfig.ip_address} />}
|
||||
<Row label={t('wizard.unprivileged')} value={lxcConfig.unprivileged ? t('common.yes') : t('common.no')} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between border-t pt-4">
|
||||
<Button variant="outline" onClick={stepIdx === 0 ? handleClose : prev}>
|
||||
{stepIdx === 0 ? t('common.cancel') : <><ChevronLeft className="mr-1 h-4 w-4" />{t('wizard.back')}</>}
|
||||
</Button>
|
||||
{step === 'confirm' ? (
|
||||
<Button onClick={handleDeploy} disabled={deploy.isPending}>
|
||||
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
<Button onClick={handleDeploy} disabled={deploy.isPending || deployLXC.isPending}>
|
||||
{(deploy.isPending || deployLXC.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
{t('wizard.deploy')}
|
||||
</Button>
|
||||
) : (
|
||||
@@ -390,7 +708,6 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
44
frontend/src/hooks/use-lxc-templates.ts
Normal file
44
frontend/src/hooks/use-lxc-templates.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import type { LXCTemplate, LXCDeployRequest } from '@/types';
|
||||
|
||||
export const lxcKeys = {
|
||||
templates: (serverId: number) => ['lxc-templates', serverId] as const,
|
||||
storages: (serverId: number, node?: string) => ['lxc-storages', serverId, node] as const,
|
||||
};
|
||||
|
||||
// CT templates change rarely — cache for 5 minutes
|
||||
export function useLXCTemplates(serverId?: number) {
|
||||
return useQuery({
|
||||
queryKey: lxcKeys.templates(serverId!),
|
||||
queryFn: () =>
|
||||
apiClient
|
||||
.get<{ server_id: number; templates: LXCTemplate[] }>(`/proxmox/api/lxc-templates/${serverId}`)
|
||||
.then((r) => r.templates),
|
||||
enabled: !!serverId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Storage list changes rarely — cache for 5 minutes
|
||||
export function useLXCStorages(serverId?: number, node?: string) {
|
||||
const params = node ? `?node=${encodeURIComponent(node)}` : '';
|
||||
return useQuery({
|
||||
queryKey: lxcKeys.storages(serverId!, node),
|
||||
queryFn: () =>
|
||||
apiClient
|
||||
.get<{ storages: { storage: string; type: string; avail: number; total: number }[] }>(
|
||||
`/proxmox/api/lxc-storages/${serverId}${params}`,
|
||||
)
|
||||
.then((r) => r.storages),
|
||||
enabled: !!serverId,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeployLXC() {
|
||||
return useMutation({
|
||||
mutationFn: (data: LXCDeployRequest) =>
|
||||
apiClient.post<{ task_id: number; status: string; name: string }>('/proxmox/api/lxc/deploy', data),
|
||||
});
|
||||
}
|
||||
@@ -189,7 +189,21 @@
|
||||
"retry": "Retry",
|
||||
"os_group": "OS Group",
|
||||
"ipam_network": "IPAM Network",
|
||||
"auto_allocate_ip": "Auto-allocate IP"
|
||||
"auto_allocate_ip": "Auto-allocate IP",
|
||||
"instance_type": "Instance Type",
|
||||
"select_type": "Select type",
|
||||
"type_vm": "Virtual Machine",
|
||||
"type_vm_desc": "QEMU/KVM VM from template",
|
||||
"type_lxc": "LXC Container",
|
||||
"type_lxc_desc": "Linux container from CT template",
|
||||
"lxc_template": "CT Template",
|
||||
"select_lxc_template": "Select CT template",
|
||||
"no_lxc_templates": "No CT templates found. Upload templates to Proxmox Storage first.",
|
||||
"lxc_password": "Root Password",
|
||||
"lxc_password_placeholder": "Container root password",
|
||||
"swap_mb": "Swap (MB)",
|
||||
"unprivileged": "Unprivileged container",
|
||||
"lxc_template_label": "CT Template"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Servers",
|
||||
|
||||
@@ -189,7 +189,21 @@
|
||||
"retry": "Повторить",
|
||||
"os_group": "Группа ОС",
|
||||
"ipam_network": "Сеть IPAM",
|
||||
"auto_allocate_ip": "Авто-выделение IP"
|
||||
"auto_allocate_ip": "Авто-выделение IP",
|
||||
"instance_type": "Тип инстанса",
|
||||
"select_type": "Выберите тип",
|
||||
"type_vm": "Виртуальная машина",
|
||||
"type_vm_desc": "QEMU/KVM VM из шаблона",
|
||||
"type_lxc": "LXC Контейнер",
|
||||
"type_lxc_desc": "Linux-контейнер из CT-шаблона",
|
||||
"lxc_template": "CT Шаблон",
|
||||
"select_lxc_template": "Выберите CT шаблон",
|
||||
"no_lxc_templates": "CT шаблоны не найдены. Загрузите шаблоны в Proxmox Storage.",
|
||||
"lxc_password": "Пароль root",
|
||||
"lxc_password_placeholder": "Пароль root контейнера",
|
||||
"swap_mb": "Своп (МБ)",
|
||||
"unprivileged": "Непривилегированный контейнер",
|
||||
"lxc_template_label": "CT Шаблон"
|
||||
},
|
||||
"nodes": {
|
||||
"title": "Серверы",
|
||||
|
||||
@@ -122,6 +122,41 @@ export interface VMDeployRequest {
|
||||
ssh_keys?: string;
|
||||
}
|
||||
|
||||
// ==================== LXC CT Template Types ====================
|
||||
|
||||
export interface LXCTemplate {
|
||||
volid: string;
|
||||
storage: string;
|
||||
node: string;
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface LXCDeployRequest {
|
||||
server_id: number;
|
||||
ostemplate: string;
|
||||
node?: string;
|
||||
name: string;
|
||||
vmid?: number;
|
||||
cores: number;
|
||||
memory: number;
|
||||
swap: number;
|
||||
disk: number;
|
||||
storage: string;
|
||||
bridge: string;
|
||||
ip_address?: string;
|
||||
gateway?: string;
|
||||
ipam_network_id?: number;
|
||||
ipam_pool_id?: number;
|
||||
password?: string;
|
||||
ssh_keys?: string;
|
||||
nameserver?: string;
|
||||
searchdomain?: string;
|
||||
unprivileged: boolean;
|
||||
start_after_create: boolean;
|
||||
onboot: boolean;
|
||||
}
|
||||
|
||||
// ==================== Node Types ====================
|
||||
|
||||
export interface ProxmoxNode {
|
||||
|
||||
Reference in New Issue
Block a user