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:
markmorado
2026-05-06 19:17:04 +02:00
parent adcc86f30a
commit 1cf099ba1d
8 changed files with 1000 additions and 39 deletions

View File

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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": "Серверы",

View File

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