mirror of
https://github.com/niezhicheng/pveui.git
synced 2026-05-07 06:07:29 +08:00
feat(pve): 添加pve虚拟化的硬件管理
This commit is contained in:
@@ -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='是否启用此服务器配置')
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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='需要更新的硬件配置参数(键值对)'
|
||||
)
|
||||
@@ -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.
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
1215
front-end/src/views/pve/vm/components/VirtualMachineDetailModal.vue
Normal file
1215
front-end/src/views/pve/vm/components/VirtualMachineDetailModal.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;">
|
||||
格式:数字+G(如:10G表示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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user