feat(pve): 添加pve虚拟化的硬件管理

This commit is contained in:
it民工
2025-11-25 15:09:46 +08:00
parent 0cd9768f94
commit 53ae731f17
9 changed files with 2175 additions and 407 deletions

View File

@@ -10,11 +10,8 @@ class PVEServer(BaseAuditModel):
name = models.CharField(max_length=100, verbose_name='服务器名称', help_text='PVE服务器的显示名称')
host = models.CharField(max_length=255, verbose_name='服务器地址', help_text='PVE服务器IP或域名192.168.1.100')
port = models.IntegerField(default=8006, verbose_name='端口', help_text='PVE API端口默认8006')
username = models.CharField(max_length=100, verbose_name='用户名', help_text='PVE登录用户名')
password = models.CharField(max_length=255, verbose_name='密码', help_text='PVE登录密码建议使用API Token')
token_id = models.CharField(max_length=100, blank=True, default='', verbose_name='Token ID', help_text='API Token ID如果使用Token认证')
token_secret = models.CharField(max_length=255, blank=True, default='', verbose_name='Token Secret', help_text='API Token Secret如果使用Token认证')
use_token = models.BooleanField(default=False, verbose_name='使用Token认证', help_text='是否使用Token认证而不是用户名密码')
token_id = models.CharField(max_length=100, verbose_name='Token ID', help_text='API Token ID')
token_secret = models.CharField(max_length=255, verbose_name='Token Secret', help_text='API Token Secret')
verify_ssl = models.BooleanField(default=False, verbose_name='验证SSL', help_text='是否验证SSL证书')
is_active = models.BooleanField(default=True, verbose_name='是否启用', help_text='是否启用此服务器配置')

View File

@@ -1,10 +1,7 @@
"""PVE API客户端封装Proxmox VE API调用。"""
import requests
from requests.auth import HTTPBasicAuth
from urllib.parse import urljoin
import json
from typing import Dict, List, Optional, Any
from typing import Dict, List, Optional, Any, Union
import logging
logger = logging.getLogger(__name__)
@@ -13,48 +10,36 @@ logger = logging.getLogger(__name__)
class PVEAPIClient:
"""PVE API客户端类。"""
def __init__(self, host: str, port: int = 8006, username: str = None,
password: str = None, token_id: str = None,
token_secret: str = None, use_token: bool = False,
verify_ssl: bool = False):
def __init__(self, host: str, port: int = 8006, token_id: str = None,
token_secret: str = None, verify_ssl: bool = False):
"""
初始化PVE API客户端。
Args:
host: PVE服务器地址
port: PVE API端口默认8006
username: 用户名(使用密码认证时)
password: 密码(使用密码认证时)
token_id: Token ID使用Token认证时
token_secret: Token Secret使用Token认证时
use_token: 是否使用Token认证
token_id: Token ID
token_secret: Token Secret
verify_ssl: 是否验证SSL证书
"""
self.host = host
self.port = port
self.base_url = f"https://{host}:{port}/api2/json"
self.verify_ssl = verify_ssl
self.use_token = use_token
if use_token and token_id and token_secret:
# 使用Token认证
self.auth_header = {
'Authorization': f'PVEAPIToken={token_id}={token_secret}'
}
self.auth = None
else:
# 使用用户名密码认证
self.auth = HTTPBasicAuth(username, password)
self.auth_header = {}
if not token_id or not token_secret:
raise ValueError("Token ID 和 Token Secret 是必需的")
# 使用Token认证
self.auth_header = {
'Authorization': f'PVEAPIToken={token_id}={token_secret}'
}
self.session = requests.Session()
if self.auth:
self.session.auth = self.auth
if self.auth_header:
self.session.headers.update(self.auth_header)
self.session.headers.update(self.auth_header)
self.session.verify = verify_ssl
def _request(self, method: str, endpoint: str, params: Dict = None, data: Dict = None) -> Dict:
def _request(self, method: str, endpoint: str, params: Dict = None, data: Dict = None) -> Union[Dict, List]:
"""
发送API请求。
@@ -70,7 +55,10 @@ class PVEAPIClient:
Raises:
Exception: API请求失败时抛出异常
"""
url = urljoin(self.base_url, endpoint)
# 确保endpoint以/开头然后直接拼接不使用urljoin避免路径被替换
if not endpoint.startswith('/'):
endpoint = '/' + endpoint
url = self.base_url.rstrip('/') + endpoint
try:
if method.upper() == 'GET':
@@ -84,7 +72,33 @@ class PVEAPIClient:
else:
raise ValueError(f"不支持的HTTP方法: {method}")
response.raise_for_status()
# 检查HTTP状态码
if response.status_code >= 400:
# 尝试解析错误信息
try:
error_data = response.json()
# PVE API错误格式可能是 {"errors": {"param": "error message"}} 或 {"data": null, "errors": {...}}
if 'errors' in error_data:
errors = error_data['errors']
if isinstance(errors, dict):
# 提取所有错误信息
error_messages = []
for key, value in errors.items():
if isinstance(value, dict) and 'message' in value:
error_messages.append(f"{key}: {value['message']}")
elif isinstance(value, str):
error_messages.append(f"{key}: {value}")
else:
error_messages.append(f"{key}: {str(value)}")
error_msg = '\n'.join(error_messages) if error_messages else str(errors)
else:
error_msg = str(errors)
else:
error_msg = error_data.get('message', '') or response.text
except:
error_msg = response.text or f"HTTP {response.status_code}"
raise Exception(f"PVE API错误 ({response.status_code}): {error_msg}")
result = response.json()
# PVE API返回格式: {"data": {...}}
@@ -93,24 +107,34 @@ class PVEAPIClient:
return result
except requests.exceptions.RequestException as e:
logger.error(f"PVE API请求失败: {method} {url}, 错误: {str(e)}")
raise Exception(f"PVE API请求失败: {str(e)}")
error_msg = str(e)
if hasattr(e, 'response') and e.response is not None:
try:
error_data = e.response.json()
error_msg = error_data.get('errors', {}).get('message', '') or error_data.get('message', '') or e.response.text
except:
error_msg = e.response.text or str(e)
logger.error(f"PVE API请求失败: {method} {url}, 错误: {error_msg}")
raise Exception(f"PVE API请求失败: {error_msg}")
def get_version(self) -> Dict:
"""获取PVE版本信息。"""
return self._request('GET', '/version')
def get_nodes(self) -> dict:
def get_nodes(self) -> List[Dict]:
"""获取所有节点列表。"""
return self._request('GET', '/nodes')
result = self._request('GET', '/nodes')
return result if isinstance(result, list) else [result]
def get_node_status(self, node: str) -> Dict:
"""获取节点状态。"""
return self._request('GET', f'/nodes/{node}/status')
result = self._request('GET', f'/nodes/{node}/status')
return result if isinstance(result, dict) else {}
def get_vms(self, node: str) -> dict:
def get_vms(self, node: str) -> List[Dict]:
"""获取节点上的所有虚拟机。"""
return self._request('GET', f'/nodes/{node}/qemu')
result = self._request('GET', f'/nodes/{node}/qemu')
return result if isinstance(result, list) else [result]
def get_vm_status(self, node: str, vmid: int) -> Dict:
"""获取虚拟机状态。"""
@@ -120,26 +144,38 @@ class PVEAPIClient:
"""获取虚拟机配置。"""
return self._request('GET', f'/nodes/{node}/qemu/{vmid}/config')
def create_vm(self, node: str, vmid: int, config: Dict) -> dict:
def update_vm_config(self, node: str, vmid: int, params: Dict) -> Dict:
"""
更新虚拟机硬件配置。
Args:
node: 节点名称
vmid: 虚拟机ID
params: 需要更新的配置参数
"""
if not params:
raise ValueError("params 不能为空")
return self._request('POST', f'/nodes/{node}/qemu/{vmid}/config', params=params)
def create_vm(self, node: str, vmid: int, config: Dict) -> Dict:
"""
创建虚拟机。
Args:
node: 节点名称
vmid: 虚拟机ID
config: 虚拟机配置字典
vmid: 虚拟机ID已经在config中这里只是为了类型检查
config: 虚拟机配置字典包含所有参数包括vmid
Returns:
UPID任务ID
"""
# 创建虚拟机
params = {'vmid': vmid}
params.update(config)
result = self._request('POST', f'/nodes/{node}/qemu', params=params)
# PVE API创建虚拟机使用URL参数表单格式
# config已经包含了所有参数包括vmid
result = self._request('POST', f'/nodes/{node}/qemu', params=config)
return result
def clone_vm(self, node: str, newid: int, source_vmid: int,
name: str = None, full: bool = False) -> str:
name: str = None, full: bool = False) -> Dict:
"""
克隆虚拟机。
@@ -163,35 +199,83 @@ class PVEAPIClient:
result = self._request('POST', f'/nodes/{node}/qemu/{source_vmid}/clone', params=params)
return result
def start_vm(self, node: str, vmid: int) -> str:
def start_vm(self, node: str, vmid: int) -> Dict:
"""启动虚拟机。"""
return self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/start')
result = self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/start')
return result if isinstance(result, dict) else {}
def stop_vm(self, node: str, vmid: int) -> str:
def stop_vm(self, node: str, vmid: int) -> Dict:
"""停止虚拟机。"""
return self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/stop')
result = self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/stop')
return result if isinstance(result, dict) else {}
def shutdown_vm(self, node: str, vmid: int) -> str:
def shutdown_vm(self, node: str, vmid: int) -> Dict:
"""关闭虚拟机(优雅关闭)。"""
return self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/shutdown')
result = self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/shutdown')
return result if isinstance(result, dict) else {}
def reboot_vm(self, node: str, vmid: int) -> str:
def reboot_vm(self, node: str, vmid: int) -> Dict:
"""重启虚拟机。"""
return self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/reboot')
result = self._request('POST', f'/nodes/{node}/qemu/{vmid}/status/reboot')
return result if isinstance(result, dict) else {}
def delete_vm(self, node: str, vmid: int) -> str:
def delete_vm(self, node: str, vmid: int) -> Dict:
"""删除虚拟机。"""
return self._request('DELETE', f'/nodes/{node}/qemu/{vmid}')
result = self._request('DELETE', f'/nodes/{node}/qemu/{vmid}')
return result if isinstance(result, dict) else {}
def get_storage(self, node: str) -> List[Dict]:
"""获取存储列表。"""
return self._request('GET', f'/nodes/{node}/storage')
result = self._request('GET', f'/nodes/{node}/storage')
return result if isinstance(result, list) else [result] if result else []
def get_network(self, node: str) -> List[Dict]:
"""获取网络接口列表。"""
return self._request('GET', f'/nodes/{node}/network')
result = self._request('GET', f'/nodes/{node}/network')
return result if isinstance(result, list) else [result] if result else []
def get_task_status(self, node: str, upid: str) -> Dict:
"""获取任务状态。"""
return self._request('GET', f'/nodes/{node}/tasks/{upid}/status')
def get_storage_content(self, node: str, storage: str, content_type: str = None) -> List[Dict]:
"""
获取存储内容。
Args:
node: 节点名称
storage: 存储名称
content_type: 内容类型iso, vztmpl, images等如果为None则返回所有类型
Returns:
存储内容列表
"""
params = {}
if content_type:
params['content'] = content_type
result = self._request('GET', f'/nodes/{node}/storage/{storage}/content', params=params)
return result if isinstance(result, list) else [result] if result else []
def get_next_vmid(self, vmid: int = None) -> int:
"""
获取下一个可用的VMID。
Args:
vmid: 可选的VMID用于检查该VMID是否可用在检查时
Returns:
下一个可用的VMID
"""
params = {}
if vmid:
params['vmid'] = vmid
result = self._request('GET', '/cluster/nextid', params=params)
# PVE API返回格式可能是 {"data": 100} 或直接返回数字
if isinstance(result, dict) and 'data' in result:
return int(result['data'])
elif isinstance(result, (int, str)):
return int(result)
else:
# 如果API返回格式不符合预期回退到默认值
return 100

View File

@@ -11,8 +11,8 @@ class PVEServerListSerializer(BaseModelSerializer):
class Meta:
model = PVEServer
fields = [
'id', 'name', 'host', 'port', 'username',
'use_token', 'is_active', 'created_at', 'updated_at'
'id', 'name', 'host', 'port', 'token_id',
'is_active', 'created_at', 'updated_at'
]
read_only_fields = ['created_at', 'updated_at']
@@ -27,7 +27,6 @@ class PVEServerDetailSerializer(BaseModelSerializer):
fields = '__all__'
read_only_fields = ['created_at', 'updated_at', 'created_by', 'updated_by']
extra_kwargs = {
'password': {'write_only': True},
'token_secret': {'write_only': True},
}
@@ -42,8 +41,7 @@ class PVEServerCreateSerializer(BaseModelSerializer):
class Meta:
model = PVEServer
fields = [
'name', 'host', 'port', 'username', 'password',
'token_id', 'token_secret', 'use_token',
'name', 'host', 'port', 'token_id', 'token_secret',
'verify_ssl', 'is_active', 'remark'
]
@@ -54,8 +52,7 @@ class PVEServerUpdateSerializer(BaseModelSerializer):
class Meta:
model = PVEServer
fields = [
'name', 'host', 'port', 'username', 'password',
'token_id', 'token_secret', 'use_token',
'name', 'host', 'port', 'token_id', 'token_secret',
'verify_ssl', 'is_active', 'remark'
]
@@ -97,13 +94,19 @@ class VirtualMachineCreateSerializer(serializers.Serializer):
server_id = serializers.IntegerField(help_text='PVE服务器ID')
node = serializers.CharField(help_text='节点名称')
vmid = serializers.IntegerField(help_text='虚拟机ID如果不提供则自动分配', required=False)
vmid = serializers.IntegerField(help_text='虚拟机ID如果不提供则自动分配', required=False, allow_null=True)
name = serializers.CharField(max_length=255, help_text='虚拟机名称')
cores = serializers.IntegerField(default=1, help_text='CPU核心数', required=False)
sockets = serializers.IntegerField(default=1, help_text='CPU Sockets', required=False)
cores = serializers.IntegerField(default=1, help_text='每Socket核心数', required=False)
cpu = serializers.CharField(default='x86-64-v2-AES', help_text='CPU类型', required=False)
memory = serializers.IntegerField(default=512, help_text='内存(MB)', required=False)
scsihw = serializers.CharField(default='virtio-scsi-single', help_text='SCSI硬件类型', required=False)
numa = serializers.BooleanField(default=False, help_text='是否启用NUMA', required=False)
disk_size = serializers.CharField(default='10G', help_text='磁盘大小10G', required=False)
disk_storage = serializers.CharField(help_text='存储名称', required=False)
iso_storage = serializers.CharField(required=False, help_text='ISO存储名称可选如果不提供则使用disk_storage')
network_bridge = serializers.CharField(default='vmbr0', help_text='网络桥接', required=False)
network_firewall = serializers.BooleanField(default=True, help_text='是否启用防火墙', required=False)
ostype = serializers.CharField(default='l26', help_text='操作系统类型', required=False)
iso = serializers.CharField(required=False, help_text='ISO镜像路径可选')
description = serializers.CharField(required=False, allow_blank=True, help_text='描述')
@@ -125,3 +128,12 @@ class VirtualMachineActionSerializer(serializers.Serializer):
help_text='操作类型start-启动, stop-停止, shutdown-关闭, reboot-重启'
)
class VirtualMachineHardwareUpdateSerializer(serializers.Serializer):
"""虚拟机硬件更新序列化器。"""
params = serializers.DictField(
child=serializers.CharField(allow_blank=True),
allow_empty=False,
help_text='需要更新的硬件配置参数(键值对)'
)

View File

@@ -1,5 +1,6 @@
"""PVE模块视图集。"""
import logging
from rest_framework import viewsets, status
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -20,9 +21,12 @@ from .serializers import (
VirtualMachineDetailSerializer,
VirtualMachineCreateSerializer,
VirtualMachineActionSerializer,
VirtualMachineHardwareUpdateSerializer,
)
from .pve_client import PVEAPIClient
logger = logging.getLogger(__name__)
class PVEServerViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.ModelViewSet):
"""PVE服务器CRUD视图集。"""
@@ -50,11 +54,8 @@ class PVEServerViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)
@@ -81,11 +82,8 @@ class PVEServerViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)
@@ -105,11 +103,8 @@ class PVEServerViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)
@@ -129,11 +124,8 @@ class PVEServerViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)
@@ -143,6 +135,73 @@ class PVEServerViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.
return Response({
'detail': f'获取存储列表失败: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get'], url_path='nodes/(?P<node>[^/.]+)/storage/(?P<storage>[^/.]+)/iso')
def node_storage_iso(self, request, pk=None, node=None, storage=None):
"""获取存储中的ISO镜像列表。"""
server = self.get_object()
try:
client = PVEAPIClient(
host=server.host,
port=server.port,
token_id=server.token_id,
token_secret=server.token_secret,
verify_ssl=server.verify_ssl
)
# 获取ISO类型的内容
content = client.get_storage_content(node, storage, content_type='iso')
# 过滤出ISO文件
iso_files = [item for item in content if item.get('content') == 'iso' and item.get('volid', '').endswith('.iso')]
return Response(iso_files)
except Exception as e:
return Response({
'detail': f'获取ISO镜像列表失败: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get'], url_path='nodes/(?P<node>[^/.]+)/network')
def node_network(self, request, pk=None, node=None):
"""获取节点网络接口列表。"""
server = self.get_object()
try:
client = PVEAPIClient(
host=server.host,
port=server.port,
token_id=server.token_id,
token_secret=server.token_secret,
verify_ssl=server.verify_ssl
)
network = client.get_network(node)
return Response(network)
except Exception as e:
return Response({
'detail': f'获取网络接口失败: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get'], url_path='next-vmid')
def next_vmid(self, request, pk=None):
"""获取下一个可用的VMID。"""
server = self.get_object()
try:
client = PVEAPIClient(
host=server.host,
port=server.port,
token_id=server.token_id,
token_secret=server.token_secret,
verify_ssl=server.verify_ssl
)
# 获取下一个VMID
vmid = client.get_next_vmid()
return Response({'vmid': vmid})
except Exception as e:
return Response({
'detail': f'获取下一个VMID失败: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
class VirtualMachineViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, viewsets.ModelViewSet):
@@ -181,45 +240,106 @@ class VirtualMachineViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, view
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)
# 构建虚拟机配置
vmid = data.get('vmid')
if not vmid:
# 如果没有指定vmid从PVE API获取下一个可用的vmid
vms = client.get_vms(node)
existing_vmids = [vm.get('vmid') for vm in vms if 'vmid' in vm]
vmid = max(existing_vmids) + 1 if existing_vmids else 100
# 解析磁盘大小默认10G最小1G
raw_disk_size = data.get('disk_size', '10G') or '10G'
disk_size_str = str(raw_disk_size).strip()
disk_size_gb = 10
try:
normalized_disk_size = disk_size_str.upper()
if normalized_disk_size.endswith('G'):
disk_size_gb = int(normalized_disk_size.replace('G', '') or 0)
elif normalized_disk_size.endswith('M'):
disk_size_gb = int(normalized_disk_size.replace('M', '') or 0) // 1024
else:
disk_size_gb = int(normalized_disk_size)
except (ValueError, AttributeError, TypeError):
logger.warning(f'无法解析磁盘大小 {disk_size_str}使用默认值10GB')
disk_size_gb = 10
if disk_size_gb < 1:
disk_size_gb = 1
config = {
'name': data['name'],
'cores': data.get('cores', 1),
'memory': data.get('memory', 512),
'ostype': data.get('ostype', 'l26'),
# 构建PVE API参数使用URL参数表单格式
# PVE API创建虚拟机使用URL参数所有配置都作为URL参数传递
params = {
'vmid': vmid,
'name': str(data['name']),
'sockets': int(data.get('sockets', 1)),
'cores': int(data.get('cores', 1)),
'cpu': str(data.get('cpu', 'x86-64-v2-AES')),
'memory': int(data.get('memory', 512)),
'ostype': str(data.get('ostype', 'l26')),
'scsihw': str(data.get('scsihw', 'virtio-scsi-single')),
'numa': 1 if data.get('numa', False) else 0, # NUMA设置转换为0或1
}
# 磁盘配置
disk_size = data.get('disk_size', '10G')
# 磁盘配置 - PVE API格式根据存储类型不同
disk_storage = data.get('disk_storage')
if disk_storage:
config['scsi0'] = f'{disk_storage}:{disk_size}'
# 获取存储信息以确定存储类型
try:
storages = client.get_storage(node)
storage_info = next((s for s in storages if s.get('storage') == disk_storage), None)
storage_type = storage_info.get('type') if storage_info else None
# 根据存储类型使用不同的格式
if storage_type == 'rbd':
# RBD/Ceph存储格式scsi0=storage:size数字无单位
# 例如ceph:32 表示32GB
params['scsi0'] = f'{disk_storage}:{disk_size_gb}'
# 可选添加iothread参数提升性能
params['scsi0'] += ',iothread=on'
elif storage_type == 'lvm' or storage_type == 'lvmthin':
# LVM/LVM-Thin存储格式scsi0=storage:size数字无单位
# 例如local-lvm:32 表示32GB
params['scsi0'] = f'{disk_storage}:{disk_size_gb}'
else:
# 其他存储类型如dir, nfs等使用标准格式scsi0=storage:size带单位
params['scsi0'] = f'{disk_storage}:{disk_size_str}'
except Exception as e:
# 如果获取存储信息失败,尝试根据存储名称判断
logger.warning(f'获取存储信息失败: {str(e)}')
# 如果存储名称包含ceph、rbd、lvm使用数字格式
if 'ceph' in disk_storage.lower() or 'rbd' in disk_storage.lower():
params['scsi0'] = f'{disk_storage}:{disk_size_gb},iothread=on'
elif 'lvm' in disk_storage.lower():
params['scsi0'] = f'{disk_storage}:{disk_size_gb}'
else:
params['scsi0'] = f'{disk_storage}:{disk_size_str}'
# 网络配置
network_bridge = data.get('network_bridge', 'vmbr0')
config['net0'] = f'virtio,bridge={network_bridge}'
# 网络配置 - PVE API格式net0=model,bridge=bridge_name,firewall=0|1
network_bridge = str(data.get('network_bridge', 'vmbr0'))
network_firewall = 1 if data.get('network_firewall', True) else 0
params['net0'] = f'virtio,bridge={network_bridge},firewall={network_firewall}'
# ISO配置如果有
# ISO配置如果有- PVE API格式ide2=storage:iso/file.iso,media=cdrom
if data.get('iso'):
config['ide2'] = f'{disk_storage}:iso/{data["iso"]},media=cdrom'
iso_value = str(data['iso'])
# 如果ISO值是完整路径如 storage:iso/file.iso直接使用
if ':' in iso_value:
params['ide2'] = f'{iso_value},media=cdrom'
else:
# 否则使用iso_storage或disk_storage拼接
iso_storage = data.get('iso_storage') or disk_storage
if iso_storage:
params['ide2'] = f'{iso_storage}:iso/{iso_value},media=cdrom'
# 创建虚拟机
result = client.create_vm(node, vmid, config)
# 描述(如果有)
if data.get('description'):
params['description'] = str(data['description'])
# 记录配置信息用于调试
logger.info(f'创建虚拟机参数: vmid={vmid}, node={node}, params={params}')
config_snapshot = params.copy()
# 创建虚拟机 - 传递params作为URL参数
result = client.create_vm(node, vmid, params)
# 等待任务完成(简化处理,实际应该轮询任务状态)
# 这里先创建数据库记录
@@ -231,9 +351,9 @@ class VirtualMachineViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, view
status='stopped',
cpu_cores=data.get('cores', 1),
memory_mb=data.get('memory', 512),
disk_gb=int(disk_size.replace('G', '')) if 'G' in disk_size else 10,
disk_gb=disk_size_gb,
description=data.get('description', ''),
pve_config=config,
pve_config=config_snapshot,
created_by=request.user if request.user.is_authenticated else None,
)
@@ -259,11 +379,8 @@ class VirtualMachineViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, view
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)
@@ -297,6 +414,54 @@ class VirtualMachineViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, view
'detail': f'操作失败: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['post'], url_path='update-hardware')
def update_hardware(self, request, pk=None):
"""更新虚拟机硬件配置。"""
vm = self.get_object()
serializer = VirtualMachineHardwareUpdateSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
params = serializer.validated_data.get('params', {})
if not params:
return Response({
'detail': '缺少需要更新的配置参数'
}, status=status.HTTP_400_BAD_REQUEST)
try:
server = vm.server
client = PVEAPIClient(
host=server.host,
port=server.port,
token_id=server.token_id,
token_secret=server.token_secret,
verify_ssl=server.verify_ssl
)
result = client.update_vm_config(vm.node, vm.vmid, params)
# 更新数据库中的缓存配置
config = client.get_vm_config(vm.node, vm.vmid)
vm.pve_config = config
if 'cores' in config and 'sockets' in config:
vm.cpu_cores = config['cores'] * config['sockets']
elif 'cores' in config:
vm.cpu_cores = config['cores']
if 'memory' in config:
vm.memory_mb = config['memory']
vm.save()
return Response({
'success': True,
'message': '硬件配置更新已提交',
'upid': result,
'config': config
})
except Exception as e:
logger.exception('更新虚拟机硬件配置失败')
return Response({
'detail': f'更新虚拟机硬件配置失败: {str(e)}'
}, status=status.HTTP_400_BAD_REQUEST)
@action(detail=True, methods=['get'])
def sync_status(self, request, pk=None):
"""同步虚拟机状态。"""
@@ -307,11 +472,8 @@ class VirtualMachineViewSet(AuditOwnerPopulateMixin, ActionSerializerMixin, view
client = PVEAPIClient(
host=server.host,
port=server.port,
username=server.username,
password=server.password,
token_id=server.token_id,
token_secret=server.token_secret,
use_token=server.use_token,
verify_ssl=server.verify_ssl
)

Binary file not shown.

View File

@@ -97,6 +97,36 @@ export function getNodeStorage(serverId, node) {
})
}
/**
* 获取存储中的ISO镜像列表
*/
export function getStorageISO(serverId, node, storage) {
return request({
url: `/api/pve/servers/${serverId}/nodes/${node}/storage/${storage}/iso/`,
method: 'get'
})
}
/**
* 获取节点网络接口列表
*/
export function getNodeNetwork(serverId, node) {
return request({
url: `/api/pve/servers/${serverId}/nodes/${node}/network/`,
method: 'get'
})
}
/**
* 获取下一个可用的VMID
*/
export function getNextVMID(serverId) {
return request({
url: `/api/pve/servers/${serverId}/next-vmid/`,
method: 'get'
})
}
/**
* 虚拟机相关API
*/
@@ -164,3 +194,14 @@ export function syncVMStatus(id) {
})
}
/**
* 更新虚拟机硬件配置
*/
export function updateVirtualMachineHardware(id, data) {
return request({
url: `/api/pve/virtual-machines/${id}/update-hardware/`,
method: 'post',
data
})
}

View File

@@ -43,11 +43,6 @@
</a-tag>
</template>
<template #use_token="{ record }">
<a-tag :color="record.use_token ? 'blue' : 'orange'">
{{ record.use_token ? 'Token' : '密码' }}
</a-tag>
</template>
<template #actions="{ record }">
<a-button type="text" size="small" @click="handleTestConnection(record)">测试连接</a-button>
@@ -99,54 +94,24 @@
</a-col>
</a-row>
<a-form-item field="use_token" label="认证方式">
<a-switch v-model="formData.use_token" />
<template #extra>
<span style="color: #86909c; font-size: 12px">开启后使用Token认证否则使用用户名密码</span>
</template>
</a-form-item>
<template v-if="!formData.use_token">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="username" label="用户名">
<a-input
v-model="formData.username"
placeholder="PVE登录用户名"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="password" label="密码">
<a-input-password
v-model="formData.password"
placeholder="PVE登录密码"
/>
</a-form-item>
</a-col>
</a-row>
</template>
<template v-else>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="token_id" label="Token ID">
<a-input
v-model="formData.token_id"
placeholder="API Token ID"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="token_secret" label="Token Secret">
<a-input-password
v-model="formData.token_secret"
placeholder="API Token Secret"
/>
</a-form-item>
</a-col>
</a-row>
</template>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="token_id" label="Token ID">
<a-input
v-model="formData.token_id"
placeholder="API Token ID"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="token_secret" label="Token Secret">
<a-input-password
v-model="formData.token_secret"
placeholder="API Token Secret"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
@@ -191,8 +156,7 @@ const columns = [
{ title: '服务器名称', dataIndex: 'name', width: 150 },
{ title: '服务器地址', dataIndex: 'host', width: 150 },
{ title: '端口', dataIndex: 'port', width: 100 },
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '认证方式', dataIndex: 'use_token', slotName: 'use_token', width: 100 },
{ title: 'Token ID', dataIndex: 'token_id', width: 150 },
{ title: '状态', dataIndex: 'is_active', slotName: 'is_active', width: 100 },
{ title: '创建时间', dataIndex: 'created_at', width: 180 },
{ title: '操作', slotName: 'actions', width: 200, fixed: 'right' }
@@ -218,11 +182,8 @@ const formData = reactive({
name: '',
host: '',
port: 8006,
username: '',
password: '',
token_id: '',
token_secret: '',
use_token: false,
verify_ssl: false,
is_active: true,
remark: ''
@@ -232,42 +193,8 @@ const formRules = {
name: [{ required: true, message: '请输入服务器名称' }],
host: [{ required: true, message: '请输入服务器地址' }],
port: [{ required: true, message: '请输入端口' }],
username: [
{
validator: (value, callback) => {
if (!formData.use_token && !value) {
callback('请输入用户名')
}
}
}
],
password: [
{
validator: (value, callback) => {
if (!formData.use_token && !value) {
callback('请输入密码')
}
}
}
],
token_id: [
{
validator: (value, callback) => {
if (formData.use_token && !value) {
callback('请输入Token ID')
}
}
}
],
token_secret: [
{
validator: (value, callback) => {
if (formData.use_token && !value) {
callback('请输入Token Secret')
}
}
}
]
token_id: [{ required: true, message: '请输入Token ID' }],
token_secret: [{ required: true, message: '请输入Token Secret' }]
}
const formRef = ref(null)
@@ -324,11 +251,8 @@ const handleCreate = () => {
name: '',
host: '',
port: 8006,
username: '',
password: '',
token_id: '',
token_secret: '',
use_token: false,
verify_ssl: false,
is_active: true,
remark: ''
@@ -346,11 +270,8 @@ const handleEdit = async (record) => {
name: res.name,
host: res.host,
port: res.port,
username: res.username,
password: '', // 不显示密码
token_id: res.token_id,
token_secret: '', // 不显示secret
use_token: res.use_token,
verify_ssl: res.verify_ssl,
is_active: res.is_active,
remark: res.remark || ''
@@ -398,21 +319,9 @@ const handleSubmit = async () => {
await formRef.value.validate()
const submitData = { ...formData }
// 如果使用Token认证清空密码字段
if (submitData.use_token) {
submitData.password = ''
if (!submitData.token_id || !submitData.token_secret) {
Message.error('Token认证需要填写Token ID和Token Secret')
return false
}
} else {
// 如果使用密码认证清空Token字段
submitData.token_id = ''
submitData.token_secret = ''
if (!submitData.username || !submitData.password) {
Message.error('密码认证需要填写用户名和密码')
return false
}
// 编辑时不发送空的token_secret如果用户没有修改
if (isEdit.value && !submitData.token_secret) {
delete submitData.token_secret
}
if (isEdit.value && formData.id) {

File diff suppressed because it is too large Load Diff

View File

@@ -90,202 +90,362 @@
<a-modal
v-model:visible="createFormVisible"
title="创建虚拟机"
@before-ok="handleCreateSubmit"
@cancel="handleCreateCancel"
:width="800"
:width="900"
unmount-on-close
>
<a-steps
:current="currentStep"
style="margin-bottom: 24px"
>
<a-step title="一般设置" />
<a-step title="操作系统" />
<a-step title="系统" />
<a-step title="硬盘" />
<a-step title="网络" />
<a-step title="确认" />
</a-steps>
<a-form
ref="createFormRef"
:model="createFormData"
:rules="createFormRules"
layout="vertical"
>
<a-form-item field="server_id" label="PVE服务器">
<a-select
v-model="createFormData.server_id"
placeholder="请选择PVE服务器"
@change="handleServerSelect"
>
<a-option
v-for="server in servers"
:key="server.id"
:value="server.id"
<!-- 步骤1: 一般设置 -->
<div v-show="currentStep === 0">
<a-form-item field="server_id" label="PVE服务器">
<a-select
v-model="createFormData.server_id"
placeholder="请选择PVE服务器"
@change="handleServerSelect"
>
{{ server.name }}
</a-option>
</a-select>
</a-form-item>
<a-form-item field="node" label="节点">
<a-select
v-model="createFormData.node"
placeholder="请选择节点"
:loading="nodesLoading"
:disabled="!createFormData.server_id"
@change="handleNodeChange"
>
<a-option
v-for="node in nodes"
:key="node.node"
:value="node.node"
>
{{ node.node }} ({{ node.status || '未知' }})
</a-option>
</a-select>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="vmid" label="虚拟机ID可选">
<a-input-number
v-model="createFormData.vmid"
:min="100"
placeholder="留空自动分配"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="虚拟机名称">
<a-input
v-model="createFormData.name"
placeholder="请输入虚拟机名称"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="8">
<a-form-item field="cores" label="CPU核心数">
<a-input-number
v-model="createFormData.cores"
:min="1"
:max="32"
placeholder="默认1"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="memory" label="内存(MB)">
<a-input-number
v-model="createFormData.memory"
:min="512"
:step="512"
placeholder="默认512"
/>
</a-form-item>
</a-col>
<a-col :span="8">
<a-form-item field="disk_size" label="磁盘大小">
<a-input
v-model="createFormData.disk_size"
placeholder="如10G"
/>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="disk_storage" label="存储">
<a-select
v-model="createFormData.disk_storage"
placeholder="请选择存储"
:loading="storageLoading"
:disabled="!createFormData.node"
<a-option
v-for="server in servers"
:key="server.id"
:value="server.id"
>
<a-option
v-for="storage in storages"
:key="storage.storage"
:value="storage.storage"
{{ server.name }}
</a-option>
</a-select>
</a-form-item>
<a-form-item field="node" label="节点">
<a-select
v-model="createFormData.node"
placeholder="请选择节点"
:loading="nodesLoading"
:disabled="!createFormData.server_id"
@change="handleNodeChange"
>
<a-option
v-for="node in nodes"
:key="node.node"
:value="node.node"
>
{{ node.node }} ({{ node.status || '未知' }})
</a-option>
</a-select>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="vmid" label="虚拟机ID可选">
<a-input-number
v-model="createFormData.vmid"
:min="100"
placeholder="留空自动分配"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="name" label="虚拟机名称">
<a-input
v-model="createFormData.name"
placeholder="请输入虚拟机名称"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="description" label="描述">
<a-textarea
v-model="createFormData.description"
placeholder="请输入虚拟机描述(可选)"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</div>
<!-- 步骤2: 操作系统 -->
<div v-show="currentStep === 1">
<a-form-item field="ostype" label="操作系统类型">
<a-select v-model="createFormData.ostype" placeholder="请选择操作系统类型">
<a-option value="l26">Linux 2.6+</a-option>
<a-option value="l24">Linux 2.4</a-option>
<a-option value="w2k">Windows 2000</a-option>
<a-option value="w2k3">Windows 2003</a-option>
<a-option value="w2k8">Windows 2008</a-option>
<a-option value="wvista">Windows Vista</a-option>
<a-option value="win7">Windows 7</a-option>
<a-option value="win8">Windows 8</a-option>
<a-option value="win10">Windows 10</a-option>
<a-option value="win11">Windows 11</a-option>
<a-option value="other">其他</a-option>
</a-select>
</a-form-item>
<a-form-item field="iso_storage" label="ISO存储可选">
<a-select
v-model="createFormData.iso_storage"
placeholder="请选择存储用于ISO镜像可选"
:loading="storageLoading"
:disabled="!createFormData.node"
@change="handleISOStorageChange"
allow-clear
>
<a-option
v-for="storage in storages"
:key="storage.storage"
:value="storage.storage"
>
{{ storage.storage }} ({{ storage.type }})
</a-option>
</a-select>
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
仅在选择ISO镜像时需要
</span>
</template>
</a-form-item>
<a-form-item field="iso" label="ISO镜像可选">
<a-select
v-model="createFormData.iso"
placeholder="请选择ISO镜像或留空"
:loading="isoLoading"
:disabled="!createFormData.iso_storage"
allow-clear
allow-search
>
<a-option
v-for="iso in isoList"
:key="iso.volid"
:value="iso.volid"
>
{{ iso.volid }}
</a-option>
</a-select>
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
如果不选择ISO镜像将创建一个空虚拟机
</span>
</template>
</a-form-item>
</div>
<!-- 步骤3: 系统 -->
<div v-show="currentStep === 2">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="sockets" label="CPU Sockets">
<a-input-number
v-model="createFormData.sockets"
:min="1"
:max="8"
placeholder="默认1"
style="width: 100%"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="cores" label="每Socket核心数">
<a-input-number
v-model="createFormData.cores"
:min="1"
:max="32"
placeholder="默认1"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
<a-form-item field="cpu" label="CPU类型">
<a-select
v-model="createFormData.cpu"
placeholder="请选择CPU类型"
allow-search
>
<a-option value="host">host主机CPU</a-option>
<a-option value="x86-64-v2-AES">x86-64-v2-AES</a-option>
<a-option value="x86-64-v3">x86-64-v3</a-option>
<a-option value="x86-64-v4">x86-64-v4</a-option>
<a-option value="kvm64">kvm64</a-option>
<a-option value="qemu64">qemu64</a-option>
</a-select>
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
总CPU核心数 = Sockets × 每Socket核心数
</span>
</template>
</a-form-item>
<a-form-item field="memory" label="内存(MB)">
<a-input-number
v-model="createFormData.memory"
:min="512"
:step="512"
placeholder="默认512"
style="width: 100%"
/>
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
建议值512MB, 1024MB, 2048MB, 4096MB等
</span>
</template>
</a-form-item>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item field="scsihw" label="SCSI硬件类型">
<a-select
v-model="createFormData.scsihw"
placeholder="请选择SCSI硬件类型"
>
{{ storage.storage }} ({{ storage.type }})
</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="network_bridge" label="网络桥接">
<a-input
v-model="createFormData.network_bridge"
placeholder="默认vmbr0"
/>
</a-form-item>
</a-col>
</a-row>
<a-option value="virtio-scsi-single">virtio-scsi-single</a-option>
<a-option value="virtio-scsi-pci">virtio-scsi-pci</a-option>
<a-option value="lsi">lsi</a-option>
<a-option value="lsi53c810">lsi53c810</a-option>
<a-option value="megasas">megasas</a-option>
<a-option value="pvscsi">pvscsi</a-option>
</a-select>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item field="numa" label="NUMA">
<a-switch v-model="createFormData.numa" />
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
启用NUMA非统一内存访问
</span>
</template>
</a-form-item>
</a-col>
</a-row>
</div>
<a-form-item field="ostype" label="操作系统类型">
<a-select v-model="createFormData.ostype" placeholder="请选择操作系统类型">
<a-option value="l26">Linux 2.6+</a-option>
<a-option value="l24">Linux 2.4</a-option>
<a-option value="w2k">Windows 2000</a-option>
<a-option value="w2k3">Windows 2003</a-option>
<a-option value="w2k8">Windows 2008</a-option>
<a-option value="wvista">Windows Vista</a-option>
<a-option value="win7">Windows 7</a-option>
<a-option value="win8">Windows 8</a-option>
<a-option value="win10">Windows 10</a-option>
<a-option value="win11">Windows 11</a-option>
<a-option value="other">其他</a-option>
</a-select>
</a-form-item>
<!-- 步骤4: 硬盘 -->
<div v-show="currentStep === 3">
<a-form-item field="disk_storage" label="存储">
<a-select
v-model="createFormData.disk_storage"
placeholder="请选择存储"
:loading="storageLoading"
:disabled="!createFormData.node"
>
<a-option
v-for="storage in storages"
:key="storage.storage"
:value="storage.storage"
>
{{ storage.storage }} ({{ storage.type }})
</a-option>
</a-select>
</a-form-item>
<a-form-item field="iso" label="ISO镜像可选">
<a-input
v-model="createFormData.iso"
placeholder="ISO镜像文件名ubuntu-22.04.iso"
<a-form-item field="disk_size" label="磁盘大小">
<a-input
v-model="createFormData.disk_size"
placeholder="10G, 20G, 50G"
/>
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
格式数字+G10G表示10GB
</span>
</template>
</a-form-item>
</div>
<!-- 步骤5: 网络 -->
<div v-show="currentStep === 4">
<a-form-item field="network_bridge" label="网络桥接">
<a-input
v-model="createFormData.network_bridge"
placeholder="默认vmbr0"
/>
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
通常使用vmbr0根据您的网络配置调整
</span>
</template>
</a-form-item>
<a-form-item field="network_firewall" label="防火墙">
<a-switch v-model="createFormData.network_firewall" />
<template #extra>
<span style="color: var(--color-text-3); font-size: 12px;">
启用防火墙规则
</span>
</template>
</a-form-item>
</div>
<!-- 步骤6: 确认 -->
<div v-show="currentStep === 5">
<a-descriptions
:column="1"
bordered
:data="summaryData"
/>
</a-form-item>
<a-form-item field="description" label="描述">
<a-textarea
v-model="createFormData.description"
placeholder="请输入虚拟机描述"
:auto-size="{ minRows: 2, maxRows: 4 }"
/>
</a-form-item>
</div>
</a-form>
<template #footer>
<div style="display: flex; justify-content: space-between; align-items: center;">
<a-button v-if="currentStep > 0" @click="handlePrevStep">上一步</a-button>
<span v-else></span>
<div>
<a-button @click="handleCreateCancel" style="margin-right: 8px;">取消</a-button>
<a-button
v-if="currentStep < 5"
type="primary"
@click="handleNextStep"
>
下一步
</a-button>
<a-button
v-else
type="primary"
@click="handleCreateSubmit"
:loading="submitting"
>
创建
</a-button>
</div>
</div>
</template>
</a-modal>
<!-- 详情对话框 -->
<a-modal
<virtual-machine-detail-modal
v-model:visible="detailVisible"
title="虚拟机详情"
:footer="false"
:width="800"
>
<a-descriptions
v-if="currentVM"
:column="2"
bordered
>
<a-descriptions-item label="虚拟机ID">{{ currentVM.vmid }}</a-descriptions-item>
<a-descriptions-item label="名称">{{ currentVM.name }}</a-descriptions-item>
<a-descriptions-item label="节点">{{ currentVM.node }}</a-descriptions-item>
<a-descriptions-item label="状态">
<a-tag :color="getStatusColor(currentVM.status)">
{{ getStatusText(currentVM.status) }}
</a-tag>
</a-descriptions-item>
<a-descriptions-item label="CPU核心数">{{ currentVM.cpu_cores }}</a-descriptions-item>
<a-descriptions-item label="内存">{{ currentVM.memory_mb }} MB</a-descriptions-item>
<a-descriptions-item label="磁盘">{{ currentVM.disk_gb }} GB</a-descriptions-item>
<a-descriptions-item label="IP地址">{{ currentVM.ip_address || '未分配' }}</a-descriptions-item>
<a-descriptions-item label="描述" :span="2">{{ currentVM.description || '无' }}</a-descriptions-item>
<a-descriptions-item label="创建时间" :span="2">{{ currentVM.created_at }}</a-descriptions-item>
</a-descriptions>
</a-modal>
:vm-id="detailVmId"
:fallback-record="detailFallbackRecord"
/>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ref, reactive, onMounted, computed } from 'vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus, IconDown } from '@arco-design/web-vue/es/icon'
import VirtualMachineDetailModal from './components/VirtualMachineDetailModal.vue'
import {
getPVEServers,
getPVEServerNodes,
getNodeStorage,
getStorageISO,
getNextVMID,
getVirtualMachines,
createVirtualMachine,
deleteVirtualMachine,
@@ -315,11 +475,16 @@ const tableData = ref([])
const servers = ref([])
const createFormVisible = ref(false)
const detailVisible = ref(false)
const currentVM = ref(null)
const detailVmId = ref(null)
const detailFallbackRecord = ref(null)
const nodesLoading = ref(false)
const storageLoading = ref(false)
const isoLoading = ref(false)
const nodes = ref([])
const storages = ref([])
const isoList = ref([])
const currentStep = ref(0)
const submitting = ref(false)
const pagination = reactive({
current: 1,
@@ -334,11 +499,17 @@ const createFormData = reactive({
node: '',
vmid: null,
name: '',
sockets: 1,
cores: 1,
cpu: 'x86-64-v2-AES',
memory: 512,
scsihw: 'virtio-scsi-single',
numa: false,
disk_size: '10G',
disk_storage: '',
iso_storage: '', // ISO存储可选
network_bridge: 'vmbr0',
network_firewall: true,
ostype: 'l26',
iso: '',
description: ''
@@ -350,7 +521,51 @@ const createFormRules = {
name: [{ required: true, message: '请输入虚拟机名称' }],
cores: [{ required: true, message: '请输入CPU核心数' }],
memory: [{ required: true, message: '请输入内存大小' }],
disk_size: [{ required: true, message: '请输入磁盘大小' }]
disk_size: [{ required: true, message: '请输入磁盘大小' }],
disk_storage: [{ required: true, message: '请选择存储' }]
}
// 计算属性:确认页面的摘要数据
const summaryData = computed(() => {
const server = servers.value.find(s => s.id === createFormData.server_id)
return [
{ label: 'PVE服务器', value: server?.name || '-' },
{ label: '节点', value: createFormData.node || '-' },
{ label: '虚拟机ID', value: createFormData.vmid || '自动分配' },
{ label: '虚拟机名称', value: createFormData.name || '-' },
{ label: '描述', value: createFormData.description || '无' },
{ label: '操作系统类型', value: getOSTypeText(createFormData.ostype) },
{ label: 'ISO存储', value: createFormData.iso_storage || '无' },
{ label: 'ISO镜像', value: createFormData.iso || '无' },
{ label: 'CPU Sockets', value: createFormData.sockets || '-' },
{ label: '每Socket核心数', value: createFormData.cores || '-' },
{ label: '总CPU核心数', value: (createFormData.sockets || 1) * (createFormData.cores || 1) },
{ label: 'CPU类型', value: createFormData.cpu || '-' },
{ label: '内存', value: `${createFormData.memory || '-'} MB` },
{ label: 'SCSI硬件类型', value: createFormData.scsihw || '-' },
{ label: 'NUMA', value: createFormData.numa ? '启用' : '禁用' },
{ label: '存储', value: createFormData.disk_storage || '-' },
{ label: '磁盘大小', value: createFormData.disk_size || '-' },
{ label: '网络桥接', value: createFormData.network_bridge || '-' },
{ label: '防火墙', value: createFormData.network_firewall ? '启用' : '禁用' }
]
})
const getOSTypeText = (ostype) => {
const map = {
'l26': 'Linux 2.6+',
'l24': 'Linux 2.4',
'w2k': 'Windows 2000',
'w2k3': 'Windows 2003',
'w2k8': 'Windows 2008',
'wvista': 'Windows Vista',
'win7': 'Windows 7',
'win8': 'Windows 8',
'win10': 'Windows 10',
'win11': 'Windows 11',
'other': '其他'
}
return map[ostype] || ostype
}
const createFormRef = ref(null)
@@ -442,22 +657,30 @@ const handlePageSizeChange = (pageSize) => {
const handleCreate = () => {
createFormVisible.value = true
currentStep.value = 0
Object.assign(createFormData, {
server_id: null,
node: '',
vmid: null,
name: '',
sockets: 1,
cores: 1,
cpu: 'x86-64-v2-AES',
memory: 512,
scsihw: 'virtio-scsi-single',
numa: false,
disk_size: '10G',
disk_storage: '',
iso_storage: '',
network_bridge: 'vmbr0',
network_firewall: true,
ostype: 'l26',
iso: '',
description: ''
})
nodes.value = []
storages.value = []
isoList.value = []
}
const handleServerSelect = async (serverId) => {
@@ -466,13 +689,32 @@ const handleServerSelect = async (serverId) => {
storages.value = []
createFormData.node = ''
createFormData.disk_storage = ''
createFormData.iso_storage = ''
isoList.value = []
createFormData.iso = ''
return
}
// 清空节点和存储
createFormData.node = ''
createFormData.disk_storage = ''
createFormData.iso_storage = ''
storages.value = []
isoList.value = []
createFormData.iso = ''
// 如果还没有VMID尝试获取下一个可用的VMID
if (!createFormData.vmid) {
try {
const res = await getNextVMID(serverId)
if (res && res.vmid) {
createFormData.vmid = res.vmid
}
} catch (error) {
// 获取VMID失败不影响继续操作用户仍可手动输入
console.warn('获取下一个VMID失败', error)
}
}
nodesLoading.value = true
try {
@@ -490,11 +732,30 @@ const handleNodeChange = async (node) => {
if (!node || !createFormData.server_id) {
storages.value = []
createFormData.disk_storage = ''
createFormData.iso_storage = ''
isoList.value = []
createFormData.iso = ''
return
}
// 清空存储选择
createFormData.disk_storage = ''
createFormData.iso_storage = ''
isoList.value = []
createFormData.iso = ''
// 自动获取下一个可用的VMID
if (!createFormData.vmid) {
try {
const res = await getNextVMID(createFormData.server_id)
if (res && res.vmid) {
createFormData.vmid = res.vmid
}
} catch (error) {
// 获取VMID失败不影响继续操作用户仍可手动输入
console.warn('获取下一个VMID失败', error)
}
}
storageLoading.value = true
try {
@@ -508,25 +769,111 @@ const handleNodeChange = async (node) => {
}
}
const handleISOStorageChange = async (storage) => {
// 如果清空存储也清空ISO选择
if (!storage) {
isoList.value = []
createFormData.iso = ''
return
}
if (!createFormData.node || !createFormData.server_id) {
isoList.value = []
createFormData.iso = ''
return
}
isoLoading.value = true
try {
const res = await getStorageISO(createFormData.server_id, createFormData.node, storage)
isoList.value = Array.isArray(res) ? res : []
// 如果之前选择的ISO不在新列表中清空ISO选择
if (createFormData.iso && !isoList.value.find(iso => iso.volid === createFormData.iso)) {
createFormData.iso = ''
}
} catch (error) {
// ISO列表获取失败不影响创建只记录错误
console.warn('获取ISO镜像列表失败', error)
isoList.value = []
createFormData.iso = ''
} finally {
isoLoading.value = false
}
}
// 步骤验证规则
const stepValidationRules = {
0: ['server_id', 'node', 'name'], // 一般设置
1: ['ostype'], // 操作系统ISO可选
2: ['sockets', 'cores', 'memory', 'cpu', 'scsihw'], // 系统
3: ['disk_storage', 'disk_size'], // 硬盘
4: ['network_bridge'], // 网络
5: [] // 确认(无需验证)
}
const handleNextStep = async () => {
try {
// 验证当前步骤的必填字段
const fieldsToValidate = stepValidationRules[currentStep.value]
if (fieldsToValidate && fieldsToValidate.length > 0) {
await createFormRef.value.validate(fieldsToValidate)
}
// 特殊处理如果选择了ISO需要确保选择了ISO存储
if (currentStep.value === 1 && createFormData.iso && !createFormData.iso_storage) {
Message.warning('选择ISO镜像需要先选择ISO存储')
return
}
if (currentStep.value < 5) {
currentStep.value++
}
} catch (error) {
// 验证失败,不进入下一步
if (error.errors) {
return
}
}
}
const handlePrevStep = () => {
if (currentStep.value > 0) {
currentStep.value--
}
}
const handleCreateSubmit = async () => {
try {
await createFormRef.value.validate()
submitting.value = true
await createVirtualMachine(createFormData)
// 最终验证所有必填字段
const allRequiredFields = ['server_id', 'node', 'name', 'cores', 'memory', 'disk_size', 'disk_storage', 'network_bridge']
await createFormRef.value.validate(allRequiredFields)
// 准备提交数据如果vmid为null或undefined则不包含该字段
const submitData = { ...createFormData }
if (submitData.vmid === null || submitData.vmid === undefined) {
delete submitData.vmid
}
await createVirtualMachine(submitData)
Message.success('创建虚拟机任务已提交,请稍后查看状态')
createFormVisible.value = false
currentStep.value = 0
fetchData()
} catch (error) {
if (error.errors) {
return false
return
}
Message.error('创建失败:' + (error.message || '未知错误'))
return false
} finally {
submitting.value = false
}
}
const handleCreateCancel = () => {
createFormVisible.value = false
currentStep.value = 0
}
const handleVMAction = async (record, action) => {
@@ -565,8 +912,9 @@ const handleSyncStatus = async (record) => {
}
}
const handleViewDetail = async (record) => {
currentVM.value = record
const handleViewDetail = (record) => {
detailFallbackRecord.value = record
detailVmId.value = record.id
detailVisible.value = true
}