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 .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 ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
from ._helpers import get_next_vmid, save_vm_instance, get_vm_instance
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -18,3 +19,4 @@ router.include_router(tasks_router)
|
|||||||
router.include_router(backup_router)
|
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)
|
||||||
|
|||||||
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}")
|
logger.error(f"Ошибка получения списка ISO для ноды {node}: {e}")
|
||||||
return result
|
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:
|
def force_stop_vm(self, node: str, vmid: int) -> bool:
|
||||||
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
|
"""Принудительно остановить ВМ (через SSH - аналог kill -9)"""
|
||||||
@@ -1626,7 +1688,11 @@ class ProxmoxClient:
|
|||||||
if status:
|
if status:
|
||||||
if status.get('status') == 'stopped':
|
if status.get('status') == 'stopped':
|
||||||
# Проверяем exitstatus
|
# Проверяем 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)
|
time_module.sleep(2)
|
||||||
|
|
||||||
logger.warning(f"Таймаут ожидания задачи {upid}")
|
logger.warning(f"Таймаут ожидания задачи {upid}")
|
||||||
@@ -2300,7 +2366,9 @@ class ProxmoxClient:
|
|||||||
start_after_create: bool = False,
|
start_after_create: bool = False,
|
||||||
onboot: bool = False,
|
onboot: bool = False,
|
||||||
description: str = None,
|
description: str = None,
|
||||||
features: str = None
|
features: str = None,
|
||||||
|
nameserver: str = None,
|
||||||
|
searchdomain: str = None,
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""
|
"""
|
||||||
Создать новый LXC контейнер
|
Создать новый LXC контейнер
|
||||||
@@ -2363,7 +2431,13 @@ class ProxmoxClient:
|
|||||||
|
|
||||||
if features:
|
if features:
|
||||||
params['features'] = features
|
params['features'] = features
|
||||||
|
|
||||||
|
if nameserver:
|
||||||
|
params['nameserver'] = nameserver
|
||||||
|
|
||||||
|
if searchdomain:
|
||||||
|
params['searchdomain'] = searchdomain
|
||||||
|
|
||||||
result = self.proxmox.nodes(node).lxc.post(**params)
|
result = self.proxmox.nodes(node).lxc.post(**params)
|
||||||
logger.info(f"Запущено создание LXC контейнера {vmid} ({hostname}) на {node}")
|
logger.info(f"Запущено создание LXC контейнера {vmid} ({hostname}) на {node}")
|
||||||
return result
|
return result
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ 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 } from 'lucide-react';
|
import { Server, Cpu, CheckCircle, ChevronLeft, ChevronRight, Loader2, Monitor, Container, HardDrive } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -12,12 +12,14 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { useServers } from '@/hooks/use-nodes';
|
import { useServers } from '@/hooks/use-nodes';
|
||||||
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
import { useTemplates, useTemplateGroups } from '@/hooks/use-templates';
|
||||||
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
import { useIPAMNetworks } from '@/hooks/use-ipam';
|
||||||
|
import { useLXCTemplates, useLXCStorages, useDeployLXC } from '@/hooks/use-lxc-templates';
|
||||||
import { apiClient } from '@/lib/api-client';
|
import { apiClient } from '@/lib/api-client';
|
||||||
import { useDeployTasksStore } from '@/stores/deploy-tasks-store';
|
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';
|
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];
|
type Step = (typeof STEPS)[number];
|
||||||
|
|
||||||
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
|
export default function CreateInstanceWizard({ onClose }: { onClose?: () => void }) {
|
||||||
@@ -29,8 +31,11 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
|
|
||||||
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
const addDeployTask = useDeployTasksStore((s) => s.addTask);
|
||||||
|
|
||||||
// State
|
// ── Shared state ──────────────────────────────────────────────────────────
|
||||||
|
const [kind, setKind] = useState<InstanceKind>('lxc');
|
||||||
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
|
const [selectedServer, setSelectedServer] = useState<ProxmoxServer | null>(null);
|
||||||
|
|
||||||
|
// ── VM state ──────────────────────────────────────────────────────────────
|
||||||
const [selectedNode, setSelectedNode] = useState('');
|
const [selectedNode, setSelectedNode] = useState('');
|
||||||
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
|
const [selectedTemplate, setSelectedTemplate] = useState<OSTemplate | null>(null);
|
||||||
const [config, setConfig] = useState<{
|
const [config, setConfig] = useState<{
|
||||||
@@ -63,11 +68,34 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
ssh_keys: '',
|
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
|
// Queries
|
||||||
const { data: servers = [] } = useServers();
|
const { data: servers = [] } = useServers();
|
||||||
const { data: groups = [] } = useTemplateGroups();
|
const { data: groups = [] } = useTemplateGroups();
|
||||||
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
|
const { data: templates = [] } = useTemplates(undefined, selectedServer?.id);
|
||||||
const { data: networks = [] } = useIPAMNetworks();
|
const { data: networks = [] } = useIPAMNetworks();
|
||||||
|
const { data: lxcTemplates = [], isLoading: lxcLoading } = useLXCTemplates(selectedServer?.id);
|
||||||
|
const { data: lxcStorages = [] } = useLXCStorages(selectedServer?.id);
|
||||||
|
|
||||||
const deploy = useMutation({
|
const deploy = useMutation({
|
||||||
mutationFn: (data: VMDeployRequest) =>
|
mutationFn: (data: VMDeployRequest) =>
|
||||||
@@ -89,6 +117,8 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
onError: (err: Error) => toast.error(err.message),
|
onError: (err: Error) => toast.error(err.message),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const deployLXC = useDeployLXC();
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
const i = STEPS.indexOf(step);
|
const i = STEPS.indexOf(step);
|
||||||
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
|
if (i < STEPS.length - 1) setStep(STEPS[i + 1]);
|
||||||
@@ -100,30 +130,75 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
|
|
||||||
const canNext = () => {
|
const canNext = () => {
|
||||||
if (step === 'server') return !!selectedServer;
|
if (step === 'server') return !!selectedServer;
|
||||||
if (step === 'template') return !!selectedTemplate;
|
if (step === 'type') return !!kind;
|
||||||
if (step === 'config') return config.name.length >= 2;
|
if (step === 'template') return kind === 'vm' ? !!selectedTemplate : !!selectedLXCTemplate;
|
||||||
|
if (step === 'config') return kind === 'vm' ? config.name.length >= 2 : lxcConfig.name.length >= 2;
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeploy = () => {
|
const handleDeploy = () => {
|
||||||
if (!selectedTemplate) return;
|
if (kind === 'lxc') {
|
||||||
deploy.mutate({
|
if (!selectedLXCTemplate || !selectedServer) return;
|
||||||
template_id: selectedTemplate.id,
|
deployLXC.mutate(
|
||||||
name: config.name,
|
{
|
||||||
target_node: selectedNode || undefined,
|
server_id: selectedServer.id,
|
||||||
cores: config.cores,
|
ostemplate: selectedLXCTemplate.volid,
|
||||||
memory: config.memory,
|
node: selectedLXCTemplate.node || undefined,
|
||||||
disk: config.disk,
|
name: lxcConfig.name,
|
||||||
target_storage: config.storage,
|
cores: lxcConfig.cores,
|
||||||
start_after_create: config.start_after_create,
|
memory: lxcConfig.memory,
|
||||||
network_bridge: config.bridge,
|
swap: lxcConfig.swap,
|
||||||
ip_address: config.ip_address || undefined,
|
disk: lxcConfig.disk,
|
||||||
gateway: config.gateway || undefined,
|
storage: lxcConfig.storage,
|
||||||
ipam_network_id: config.ipam_network_id || undefined,
|
bridge: lxcConfig.bridge,
|
||||||
cloud_init_user: config.cloud_init_user || undefined,
|
ip_address: lxcConfig.ip_address || undefined,
|
||||||
cloud_init_password: config.cloud_init_password || undefined,
|
gateway: lxcConfig.gateway || undefined,
|
||||||
ssh_keys: config.ssh_keys || 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,
|
||||||
|
name: config.name,
|
||||||
|
target_node: selectedNode || undefined,
|
||||||
|
cores: config.cores,
|
||||||
|
memory: config.memory,
|
||||||
|
disk: config.disk,
|
||||||
|
target_storage: config.storage,
|
||||||
|
start_after_create: config.start_after_create,
|
||||||
|
network_bridge: config.bridge,
|
||||||
|
ip_address: config.ip_address || undefined,
|
||||||
|
gateway: config.gateway || undefined,
|
||||||
|
ipam_network_id: config.ipam_network_id || undefined,
|
||||||
|
cloud_init_user: config.cloud_init_user || undefined,
|
||||||
|
cloud_init_password: config.cloud_init_password || undefined,
|
||||||
|
ssh_keys: config.ssh_keys || undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Apply template defaults when template selected
|
// Apply template defaults when template selected
|
||||||
@@ -164,7 +239,7 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
<Card
|
<Card
|
||||||
key={srv.id}
|
key={srv.id}
|
||||||
className={`cursor-pointer transition-colors hover:border-blue-500/50 ${selectedServer?.id === srv.id ? 'border-blue-500 bg-blue-500/5' : ''}`}
|
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">
|
<CardContent className="flex items-center gap-3 p-4">
|
||||||
<Server className="h-8 w-8 text-muted-foreground" />
|
<Server className="h-8 w-8 text-muted-foreground" />
|
||||||
@@ -182,8 +257,43 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 2: Template */}
|
{/* Step 2: Type selection */}
|
||||||
{step === 'template' && (
|
{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">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
|
<h3 className="text-lg font-semibold">{t('wizard.select_template')}</h3>
|
||||||
{groups.map(group => {
|
{groups.map(group => {
|
||||||
@@ -224,8 +334,50 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 3: Configuration */}
|
{/* Step 3: Template — LXC */}
|
||||||
{step === 'config' && (
|
{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">
|
<div className="space-y-6">
|
||||||
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
<h3 className="text-lg font-semibold">{t('wizard.configure')}</h3>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
@@ -350,13 +502,155 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Step 4: Confirm */}
|
{/* Step 4: Configuration — LXC */}
|
||||||
{step === 'confirm' && (
|
{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">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
|
<h3 className="text-lg font-semibold">{t('wizard.confirm')}</h3>
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-6 space-y-3">
|
<CardContent className="p-6 space-y-3">
|
||||||
<Row label={t('wizard.server_label')} value={selectedServer?.name || ''} />
|
<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('wizard.template_label')} value={selectedTemplate?.name || ''} />
|
||||||
<Row label={t('common.name')} value={config.name} />
|
<Row label={t('common.name')} value={config.name} />
|
||||||
<Row label={t('common.vcpu')} value={String(config.cores)} />
|
<Row label={t('common.vcpu')} value={String(config.cores)} />
|
||||||
@@ -371,14 +665,38 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</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 */}
|
{/* 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}>
|
<Button onClick={handleDeploy} disabled={deploy.isPending || deployLXC.isPending}>
|
||||||
{deploy.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{(deploy.isPending || deployLXC.isPending) && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
{t('wizard.deploy')}
|
{t('wizard.deploy')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
@@ -390,7 +708,6 @@ export default function CreateInstanceWizard({ onClose }: { onClose?: () => void
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ label, value }: { label: string; value: string }) {
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between">
|
<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",
|
"retry": "Retry",
|
||||||
"os_group": "OS Group",
|
"os_group": "OS Group",
|
||||||
"ipam_network": "IPAM Network",
|
"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": {
|
"nodes": {
|
||||||
"title": "Servers",
|
"title": "Servers",
|
||||||
|
|||||||
@@ -189,7 +189,21 @@
|
|||||||
"retry": "Повторить",
|
"retry": "Повторить",
|
||||||
"os_group": "Группа ОС",
|
"os_group": "Группа ОС",
|
||||||
"ipam_network": "Сеть IPAM",
|
"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": {
|
"nodes": {
|
||||||
"title": "Серверы",
|
"title": "Серверы",
|
||||||
|
|||||||
@@ -122,6 +122,41 @@ export interface VMDeployRequest {
|
|||||||
ssh_keys?: string;
|
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 ====================
|
// ==================== Node Types ====================
|
||||||
|
|
||||||
export interface ProxmoxNode {
|
export interface ProxmoxNode {
|
||||||
|
|||||||
Reference in New Issue
Block a user