mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2026-11-03 00:04:53 +00:00
feat(vm): enable community virtual machines and migrate xpack data (#14279)
* feat(vm): enable community virtual machines and migrate xpack data * fix(vm): stop ISO download polling on failure or cancellation * fix(vm): respect deleteFile when deleting failed ISO imports
This commit is contained in:
@@ -79,3 +79,5 @@ opencode.json
|
||||
superpowers
|
||||
.worktrees/
|
||||
.codex/
|
||||
|
||||
/agent/utils/vm/helper/bin/1panel-kvm*
|
||||
|
||||
@@ -32,7 +32,10 @@ build_core_on_linux:
|
||||
cd $(CORE_PATH) \
|
||||
&& CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(CORE_NAME) $(CORE_MAIN)
|
||||
|
||||
build_agent_on_linux:
|
||||
prepare_vm_helper:
|
||||
bash $(AGENT_PATH)/utils/vm/helper/prepare.sh
|
||||
|
||||
build_agent_on_linux: prepare_vm_helper
|
||||
cd $(AGENT_PATH) \
|
||||
&& CGO_ENABLED=0 GOOS=$(GOOS) GOARCH=$(GOARCH) $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(AGENT_NAME) $(AGENT_MAIN)
|
||||
|
||||
@@ -40,7 +43,7 @@ build_core_on_darwin:
|
||||
cd $(CORE_PATH) \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(CORE_NAME) $(CORE_MAIN)
|
||||
|
||||
build_agent_on_darwin:
|
||||
build_agent_on_darwin: prepare_vm_helper
|
||||
cd $(AGENT_PATH) \
|
||||
&& CGO_ENABLED=0 GOOS=linux GOARCH=amd64 $(GOBUILD) -trimpath -ldflags '-s -w' -o $(BUILD_PATH)/$(AGENT_NAME) $(AGENT_MAIN)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ var ApiGroupApp = new(ApiGroup)
|
||||
type BaseApi struct{}
|
||||
|
||||
var (
|
||||
vmService = service.NewIVMService()
|
||||
dashboardService = service.NewIDashboardService()
|
||||
|
||||
appService = service.NewIAppService()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,447 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type VMSearchResult struct {
|
||||
PageResult
|
||||
Limit int `json:"limit"` // Maximum VM count for the target node; 0 means unlimited.
|
||||
}
|
||||
|
||||
type VMCheckItem struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Message string `json:"message"`
|
||||
IsEnable bool `json:"isEnable"`
|
||||
IsExist bool `json:"isExist"`
|
||||
IsActive bool `json:"isActive"`
|
||||
}
|
||||
|
||||
type VMEnvironment struct {
|
||||
Arch string `json:"arch"`
|
||||
ArchSupported bool `json:"archSupported"`
|
||||
Ready bool `json:"ready"`
|
||||
HelperExist bool `json:"helperExist"`
|
||||
HelperMessage string `json:"helperMessage"`
|
||||
KVMExist bool `json:"kvmExist"`
|
||||
KVMMessage string `json:"kvmMessage"`
|
||||
LibvirtExist bool `json:"libvirtExist"`
|
||||
LibvirtActive bool `json:"libvirtActive"`
|
||||
LibvirtMessage string `json:"libvirtMessage"`
|
||||
StorageExist bool `json:"storageExist"`
|
||||
StorageMessage string `json:"storageMessage"`
|
||||
}
|
||||
|
||||
type VMHealthCheckItem struct {
|
||||
Level string `json:"level"`
|
||||
Check string `json:"check"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
FixCommand string `json:"fixCommand,omitempty"`
|
||||
}
|
||||
|
||||
type VMHealthCheckResult struct {
|
||||
Pass int `json:"pass"`
|
||||
Warn int `json:"warn"`
|
||||
Fail int `json:"fail"`
|
||||
Items []VMHealthCheckItem `json:"items"`
|
||||
}
|
||||
|
||||
type VMCreate struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
CPU int `json:"cpu" validate:"required,min=1"`
|
||||
Memory int64 `json:"memory" validate:"required,min=1"`
|
||||
DiskSize int64 `json:"diskSize" validate:"required,min=1"`
|
||||
OSType string `json:"osType" validate:"omitempty,oneof=windows linux other"`
|
||||
DiskBus string `json:"diskBus" validate:"omitempty,oneof=sata virtio"`
|
||||
TemplateID uint `json:"templateID"`
|
||||
DiskPath string `json:"diskPath"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
ISOPaths []string `json:"isoPaths"`
|
||||
ImagePath string `json:"imagePath"`
|
||||
Network string `json:"network" validate:"required,vm_common"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
StartAfterCreate bool `json:"startAfterCreate"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMTemplateCreate struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
TemplateName string `json:"templateName" validate:"required,vm_name"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMTemplateDelete struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
DeleteDisk bool `json:"deleteDisk"`
|
||||
TaskID string `json:"taskID"`
|
||||
}
|
||||
|
||||
type VMTemplateInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SourceVM string `json:"sourceVM"`
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory"`
|
||||
DiskSize int64 `json:"diskSize"`
|
||||
DiskBus string `json:"diskBus"`
|
||||
DiskPath string `json:"diskPath"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
Network string `json:"network"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
IsExist bool `json:"isExist"`
|
||||
Size int64 `json:"size"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMUpdate struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
CPU int `json:"cpu" validate:"required,min=1"`
|
||||
Memory int64 `json:"memory" validate:"required,min=1"`
|
||||
ISOPaths []string `json:"isoPaths"`
|
||||
Network string `json:"network" validate:"required,vm_common"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMDiskResize struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
Target string `json:"target" validate:"required,vm_common"`
|
||||
SizeGiB int64 `json:"sizeGiB" validate:"required,min=1,max=8388607"`
|
||||
}
|
||||
|
||||
type VMRename struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
NewName string `json:"newName" validate:"required,vm_name"`
|
||||
}
|
||||
|
||||
type VMDelete struct {
|
||||
TaskID string `json:"taskID"`
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type VMOperate struct {
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
Operate string `json:"operate" validate:"required,oneof=start shutdown reboot destroy suspend resume"`
|
||||
}
|
||||
|
||||
type VMInfoReq struct {
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
CheckDisks bool `json:"checkDisks"`
|
||||
}
|
||||
|
||||
type VMReq struct {
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
}
|
||||
|
||||
type VMTaskResp struct {
|
||||
TaskID string `json:"taskID"`
|
||||
}
|
||||
|
||||
type VMOrphanItem struct {
|
||||
Type string `json:"type" validate:"required,oneof=orphan_domain_disk orphan_snapshot partial_file"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt int64 `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type VMOrphanClean struct {
|
||||
Items []VMOrphanItem `json:"items" validate:"required,min=1,dive"`
|
||||
}
|
||||
|
||||
type VMSnapshotReq struct {
|
||||
TaskID string `json:"taskID"`
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
SnapshotName string `json:"snapshotName" validate:"required,vm_name"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMSnapshotSearch struct {
|
||||
Name string `json:"name" validate:"required,vm_name"`
|
||||
}
|
||||
|
||||
type VMSnapshotInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
State string `json:"state"`
|
||||
Parent string `json:"parent"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Current bool `json:"current"`
|
||||
Children int `json:"children"`
|
||||
}
|
||||
|
||||
type VMIsoSearch struct {
|
||||
PageInfo
|
||||
Type string `json:"type" validate:"required"`
|
||||
Info string `json:"info"`
|
||||
}
|
||||
|
||||
type VMIsoCreate struct {
|
||||
Type string `json:"type" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMIsoUpdate struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Type string `json:"type" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMIsoImportStart struct {
|
||||
Type string `json:"type" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
Status string `json:"status" validate:"required,oneof=uploading downloading"`
|
||||
TempFileID string `json:"tempFileID"`
|
||||
TempFileType string `json:"tempFileType" validate:"omitempty,oneof=upload download"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMIsoImportUpdate struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Status string `json:"status" validate:"required,oneof=uploading downloading ready failed"`
|
||||
Error string `json:"error"`
|
||||
TempFileID string `json:"tempFileID"`
|
||||
TempFileType string `json:"tempFileType" validate:"omitempty,oneof=upload download"`
|
||||
}
|
||||
|
||||
type VMIsoDelete struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
DeleteFile bool `json:"deleteFile"`
|
||||
}
|
||||
|
||||
type VMIsoInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
IsExist bool `json:"isExist"`
|
||||
Size int64 `json:"size"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
TempFileID string `json:"tempFileID"`
|
||||
TempFileType string `json:"tempFileType"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMResourceInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
SubType string `json:"subType"`
|
||||
BridgeName string `json:"bridgeName"`
|
||||
SourceInterface string `json:"sourceInterface"`
|
||||
CIDR string `json:"cidr"`
|
||||
Gateway string `json:"gateway"`
|
||||
DHCPStart string `json:"dhcpStart"`
|
||||
DHCPEnd string `json:"dhcpEnd"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Capacity uint64 `json:"capacity"`
|
||||
Available uint64 `json:"available"`
|
||||
}
|
||||
|
||||
type VMResourceDelete struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
type VMNetworkCreate struct {
|
||||
Name string `json:"name" validate:"required,vm_common"`
|
||||
Type string `json:"type" validate:"required,oneof=nat bridge macvtap"`
|
||||
BridgeName string `json:"bridgeName"`
|
||||
SourceInterface string `json:"sourceInterface" validate:"required_if=Type macvtap"`
|
||||
CIDR string `json:"cidr"`
|
||||
Gateway string `json:"gateway"`
|
||||
DHCPStart string `json:"dhcpStart"`
|
||||
DHCPEnd string `json:"dhcpEnd"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMNetworkUpdate struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
AutoStart *bool `json:"autoStart"`
|
||||
Status *string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type VMNetworkItem struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
BridgeName string `json:"bridgeName"`
|
||||
SourceInterface string `json:"sourceInterface"`
|
||||
CIDR string `json:"cidr"`
|
||||
Gateway string `json:"gateway"`
|
||||
DHCPStart string `json:"dhcpStart"`
|
||||
DHCPEnd string `json:"dhcpEnd"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Status string `json:"status"`
|
||||
VMCount int `json:"vmCount"`
|
||||
VMs []VMResourceRef `json:"vms"`
|
||||
Registered bool `json:"registered"`
|
||||
UsedIPs []VMNetworkIPUsage `json:"usedIPs"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type VMResourceRef struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type VMNetworkIPUsage struct {
|
||||
IP string `json:"ip"`
|
||||
VMName string `json:"vmName"`
|
||||
MAC string `json:"mac"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type VMNetworkInterfaceItem struct {
|
||||
Name string `json:"name"`
|
||||
Addresses []string `json:"addresses"`
|
||||
}
|
||||
|
||||
type VMNetworkBridgeItem struct {
|
||||
Name string `json:"name"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMStorageCreate struct {
|
||||
Name string `json:"name" validate:"required,vm_common"`
|
||||
Type string `json:"type" validate:"required,oneof=dir"`
|
||||
Path string `json:"path" validate:"required"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMStorageUpdate struct {
|
||||
ID uint `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"omitempty,vm_common"`
|
||||
Path string `json:"path"`
|
||||
AutoStart *bool `json:"autoStart"`
|
||||
Status *string `json:"status"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
type VMStorageItem struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
Status string `json:"status"`
|
||||
Capacity uint64 `json:"capacity"`
|
||||
Available uint64 `json:"available"`
|
||||
VMCount int `json:"vmCount"`
|
||||
VMs []VMResourceRef `json:"vms"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type VMInfo struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
UUID string `json:"uuid"`
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory"`
|
||||
DiskSize int64 `json:"diskSize"`
|
||||
DiskBus string `json:"diskBus"`
|
||||
DiskPath string `json:"diskPath"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
ISOPaths []string `json:"isoPaths"`
|
||||
ImagePath string `json:"imagePath"`
|
||||
Network string `json:"network"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Status string `json:"status"`
|
||||
Disks []VMDisk `json:"disks"`
|
||||
Interfaces []VMInterface `json:"interfaces"`
|
||||
Graphics *VMGraphics `json:"graphics"`
|
||||
TaskID string `json:"taskID"`
|
||||
CreateTaskID string `json:"createTaskID"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMDisk struct {
|
||||
Capacity int64 `json:"capacity"`
|
||||
Format string `json:"format"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Shareable bool `json:"shareable"`
|
||||
ResizeBlocked string `json:"resizeBlocked"`
|
||||
Device string `json:"device"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Bus string `json:"bus"`
|
||||
}
|
||||
|
||||
type VMInterface struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type VMGraphics struct {
|
||||
Type string `json:"type"`
|
||||
Port string `json:"port"`
|
||||
Listen string `json:"listen"`
|
||||
}
|
||||
|
||||
type VMStats struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CPUTime uint64 `json:"cpuTime"`
|
||||
VCPU int `json:"vcpu"`
|
||||
Memory VMMemoryStats `json:"memory"`
|
||||
Disk VMDiskStats `json:"disk"`
|
||||
Network VMNetworkStats `json:"network"`
|
||||
}
|
||||
|
||||
type VMMemoryStats struct {
|
||||
AvailableKiB uint64 `json:"availableKiB"`
|
||||
UnusedKiB uint64 `json:"unusedKiB"`
|
||||
UsableKiB uint64 `json:"usableKiB"`
|
||||
RSSKiB uint64 `json:"rssKiB"`
|
||||
ActualKiB uint64 `json:"actualKiB"`
|
||||
}
|
||||
|
||||
type VMDiskStats struct {
|
||||
ReadBytes uint64 `json:"readBytes"`
|
||||
WriteBytes uint64 `json:"writeBytes"`
|
||||
ReadReqs uint64 `json:"readReqs"`
|
||||
WriteReqs uint64 `json:"writeReqs"`
|
||||
}
|
||||
|
||||
type VMNetworkStats struct {
|
||||
RxBytes uint64 `json:"rxBytes"`
|
||||
TxBytes uint64 `json:"txBytes"`
|
||||
RxPackets uint64 `json:"rxPackets"`
|
||||
TxPackets uint64 `json:"txPackets"`
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package model
|
||||
|
||||
type VirtualMachine struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name"`
|
||||
UUID string `json:"uuid"`
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory"`
|
||||
DiskSize int64 `json:"diskSize"`
|
||||
DiskBus string `json:"diskBus"`
|
||||
DiskPath string `json:"diskPath"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
ISOPaths []string `json:"isoPaths" gorm:"serializer:json"`
|
||||
ImagePath string `json:"imagePath"`
|
||||
Network string `json:"network"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Status string `json:"status"`
|
||||
TaskID string `json:"taskID"`
|
||||
CreateTaskID string `json:"createTaskID"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMTemplate struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name"`
|
||||
SourceVM string `json:"sourceVM"`
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory"`
|
||||
DiskSize int64 `json:"diskSize"`
|
||||
DiskBus string `json:"diskBus"`
|
||||
DiskPath string `json:"diskPath"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
Network string `json:"network"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMIso struct {
|
||||
BaseModel
|
||||
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
TempFileID string `json:"tempFileID"`
|
||||
TempFileType string `json:"tempFileType"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type VMNetwork struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
BridgeName string `json:"bridgeName"`
|
||||
SourceInterface string `json:"sourceInterface"`
|
||||
CIDR string `json:"cidr" gorm:"column:cidr"`
|
||||
Gateway string `json:"gateway"`
|
||||
DHCPStart string `json:"dhcpStart"`
|
||||
DHCPEnd string `json:"dhcpEnd"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type VMStorage struct {
|
||||
BaseModel
|
||||
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type VMRepo struct{}
|
||||
|
||||
type IVMRepo interface {
|
||||
Get(opts ...DBOption) (model.VirtualMachine, error)
|
||||
List() ([]model.VirtualMachine, error)
|
||||
Count() (int64, error)
|
||||
Page(page, pageSize int, opts ...DBOption) (int64, []model.VirtualMachine, error)
|
||||
Create(vm *model.VirtualMachine) error
|
||||
Update(id uint, vars map[string]interface{}) error
|
||||
Delete(opts ...DBOption) error
|
||||
WithByNetwork(network string) DBOption
|
||||
WithByStorage(storage string) DBOption
|
||||
|
||||
GetTemplate(opts ...DBOption) (model.VMTemplate, error)
|
||||
ListTemplates(opts ...DBOption) ([]model.VMTemplate, error)
|
||||
CreateTemplate(item *model.VMTemplate) error
|
||||
UpdateTemplateSource(oldName string, newName string) error
|
||||
DeleteTemplate(opts ...DBOption) error
|
||||
|
||||
GetIso(opts ...DBOption) (model.VMIso, error)
|
||||
PageIso(page, pageSize int, opts ...DBOption) (int64, []model.VMIso, error)
|
||||
ListIsos(opts ...DBOption) ([]model.VMIso, error)
|
||||
CreateIso(iso *model.VMIso) error
|
||||
UpdateIso(id uint, vars map[string]interface{}) error
|
||||
DeleteIso(opts ...DBOption) error
|
||||
|
||||
GetNetwork(opts ...DBOption) (model.VMNetwork, error)
|
||||
PageNetwork(page, pageSize int, opts ...DBOption) (int64, []model.VMNetwork, error)
|
||||
ListNetworks(opts ...DBOption) ([]model.VMNetwork, error)
|
||||
CreateNetwork(network *model.VMNetwork) error
|
||||
UpdateNetwork(id uint, vars map[string]interface{}) error
|
||||
DeleteNetwork(opts ...DBOption) error
|
||||
|
||||
GetStorage(opts ...DBOption) (model.VMStorage, error)
|
||||
PageStorage(page, pageSize int, opts ...DBOption) (int64, []model.VMStorage, error)
|
||||
ListStorages(opts ...DBOption) ([]model.VMStorage, error)
|
||||
CreateStorage(storage *model.VMStorage) error
|
||||
UpdateStorage(id uint, vars map[string]interface{}) error
|
||||
DeleteStorage(opts ...DBOption) error
|
||||
}
|
||||
|
||||
func NewIVMRepo() IVMRepo {
|
||||
return &VMRepo{}
|
||||
}
|
||||
|
||||
func (v *VMRepo) Get(opts ...DBOption) (model.VirtualMachine, error) {
|
||||
var vm model.VirtualMachine
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&vm).Error
|
||||
return vm, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) List() ([]model.VirtualMachine, error) {
|
||||
var vms []model.VirtualMachine
|
||||
err := global.DB.Order("created_at desc").Find(&vms).Error
|
||||
return vms, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) Count() (int64, error) {
|
||||
var count int64
|
||||
err := global.DB.Model(&model.VirtualMachine{}).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) Page(page, pageSize int, opts ...DBOption) (int64, []model.VirtualMachine, error) {
|
||||
var vms []model.VirtualMachine
|
||||
var total int64
|
||||
db := global.DB.Model(&model.VirtualMachine{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&vms).Error
|
||||
return total, vms, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) Create(vm *model.VirtualMachine) error {
|
||||
return global.DB.Create(vm).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) Update(id uint, vars map[string]interface{}) error {
|
||||
if paths, ok := vars["iso_paths"].([]string); ok {
|
||||
data, err := json.Marshal(paths)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vars["iso_paths"] = string(data)
|
||||
}
|
||||
return global.DB.Model(&model.VirtualMachine{}).Where("id = ?", id).Updates(vars).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) Delete(opts ...DBOption) error {
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
return db.Delete(&model.VirtualMachine{}).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) WithByNetwork(network string) DBOption {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("network = ?", network)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VMRepo) WithByStorage(storage string) DBOption {
|
||||
return func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("storage_pool = ?", storage)
|
||||
}
|
||||
}
|
||||
|
||||
func (v *VMRepo) GetTemplate(opts ...DBOption) (model.VMTemplate, error) {
|
||||
var item model.VMTemplate
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&item).Error
|
||||
return item, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) ListTemplates(opts ...DBOption) ([]model.VMTemplate, error) {
|
||||
var items []model.VMTemplate
|
||||
db := global.DB.Model(&model.VMTemplate{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Order("created_at desc").Order("id desc").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) CreateTemplate(item *model.VMTemplate) error {
|
||||
return global.DB.Create(item).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) UpdateTemplateSource(oldName string, newName string) error {
|
||||
return global.DB.Model(&model.VMTemplate{}).Where("source_vm = ?", oldName).Update("source_vm", newName).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) DeleteTemplate(opts ...DBOption) error {
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
return db.Delete(&model.VMTemplate{}).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) GetIso(opts ...DBOption) (model.VMIso, error) {
|
||||
var iso model.VMIso
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&iso).Error
|
||||
return iso, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) PageIso(page, pageSize int, opts ...DBOption) (int64, []model.VMIso, error) {
|
||||
var isos []model.VMIso
|
||||
var total int64
|
||||
db := global.DB.Model(&model.VMIso{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&isos).Error
|
||||
return total, isos, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) ListIsos(opts ...DBOption) ([]model.VMIso, error) {
|
||||
var isos []model.VMIso
|
||||
db := global.DB.Model(&model.VMIso{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Order("created_at desc").Order("id desc").Find(&isos).Error
|
||||
return isos, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) CreateIso(iso *model.VMIso) error {
|
||||
return global.DB.Create(iso).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) UpdateIso(id uint, vars map[string]interface{}) error {
|
||||
return global.DB.Model(&model.VMIso{}).Where("id = ?", id).Updates(vars).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) DeleteIso(opts ...DBOption) error {
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
return db.Delete(&model.VMIso{}).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) GetNetwork(opts ...DBOption) (model.VMNetwork, error) {
|
||||
var network model.VMNetwork
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&network).Error
|
||||
return network, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) PageNetwork(page, pageSize int, opts ...DBOption) (int64, []model.VMNetwork, error) {
|
||||
var networks []model.VMNetwork
|
||||
var total int64
|
||||
db := global.DB.Model(&model.VMNetwork{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&networks).Error
|
||||
return total, networks, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) ListNetworks(opts ...DBOption) ([]model.VMNetwork, error) {
|
||||
var networks []model.VMNetwork
|
||||
db := global.DB.Model(&model.VMNetwork{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Order("created_at desc").Order("id desc").Find(&networks).Error
|
||||
return networks, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) CreateNetwork(network *model.VMNetwork) error {
|
||||
return global.DB.Create(network).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) UpdateNetwork(id uint, vars map[string]interface{}) error {
|
||||
return global.DB.Model(&model.VMNetwork{}).Where("id = ?", id).Updates(vars).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) DeleteNetwork(opts ...DBOption) error {
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
return db.Delete(&model.VMNetwork{}).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) GetStorage(opts ...DBOption) (model.VMStorage, error) {
|
||||
var storage model.VMStorage
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.First(&storage).Error
|
||||
return storage, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) PageStorage(page, pageSize int, opts ...DBOption) (int64, []model.VMStorage, error) {
|
||||
var storages []model.VMStorage
|
||||
var total int64
|
||||
db := global.DB.Model(&model.VMStorage{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Count(&total).Error
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
err = db.Offset((page - 1) * pageSize).Limit(pageSize).Find(&storages).Error
|
||||
return total, storages, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) ListStorages(opts ...DBOption) ([]model.VMStorage, error) {
|
||||
var storages []model.VMStorage
|
||||
db := global.DB.Model(&model.VMStorage{})
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
err := db.Order("created_at desc").Order("id desc").Find(&storages).Error
|
||||
return storages, err
|
||||
}
|
||||
|
||||
func (v *VMRepo) CreateStorage(storage *model.VMStorage) error {
|
||||
return global.DB.Create(storage).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) UpdateStorage(id uint, vars map[string]interface{}) error {
|
||||
return global.DB.Model(&model.VMStorage{}).Where("id = ?", id).Updates(vars).Error
|
||||
}
|
||||
|
||||
func (v *VMRepo) DeleteStorage(opts ...DBOption) error {
|
||||
db := global.DB
|
||||
for _, opt := range opts {
|
||||
db = opt(db)
|
||||
}
|
||||
return db.Delete(&model.VMStorage{}).Error
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import "github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
|
||||
var (
|
||||
vmRepo = repo.NewIVMRepo()
|
||||
modelDownloadTaskRepo = repo.NewIModelDownloadTaskRepo()
|
||||
featuredModelRepo = repo.NewIFeaturedModelRepo()
|
||||
appRepo = repo.NewIAppRepo()
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/jinzhu/copier"
|
||||
)
|
||||
|
||||
func (v *VMService) SearchIso(req dto.SearchPageWithType) (int64, interface{}, error) {
|
||||
options := []repo.DBOption{repo.WithOrderDesc("created_at"), repo.WithOrderDesc("id")}
|
||||
if req.Type != "" {
|
||||
options = append(options, repo.WithByType(req.Type))
|
||||
}
|
||||
if req.Info != "" {
|
||||
options = append(options, repo.WithByLikeName(req.Info))
|
||||
}
|
||||
total, isos, err := vmRepo.PageIso(req.Page, req.PageSize, options...)
|
||||
if err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
dtoIsos := make([]dto.VMIsoInfo, 0, len(isos))
|
||||
for _, iso := range isos {
|
||||
var item dto.VMIsoInfo
|
||||
if err := copier.Copy(&item, &iso); err != nil {
|
||||
return 0, nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
||||
}
|
||||
item.IsExist, item.Size = loadVMResourceFileInfo(item.Path)
|
||||
dtoIsos = append(dtoIsos, item)
|
||||
}
|
||||
return total, dtoIsos, err
|
||||
}
|
||||
|
||||
func (v *VMService) ListIso() ([]dto.VMIsoInfo, error) {
|
||||
isos, err := vmRepo.ListIsos()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dtoIsos := make([]dto.VMIsoInfo, 0, len(isos))
|
||||
for _, iso := range isos {
|
||||
if iso.Status == "uploading" || iso.Status == "downloading" || iso.Status == "failed" {
|
||||
continue
|
||||
}
|
||||
var item dto.VMIsoInfo
|
||||
if err := copier.Copy(&item, &iso); err != nil {
|
||||
return nil, buserr.WithDetail("ErrStructTransform", err.Error(), nil)
|
||||
}
|
||||
item.IsExist, item.Size = loadVMResourceFileInfo(item.Path)
|
||||
dtoIsos = append(dtoIsos, item)
|
||||
}
|
||||
return dtoIsos, nil
|
||||
}
|
||||
|
||||
func (v *VMService) CreateIso(req dto.VMIsoCreate) error {
|
||||
isoItem, _ := vmRepo.GetIso(repo.WithByName(req.Name))
|
||||
if isoItem.ID != 0 {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
exists, _ := loadVMResourceFileInfo(req.Path)
|
||||
if !exists {
|
||||
return buserr.WithDetail("ErrResourceNotExists", "Resource file does not exist", nil)
|
||||
}
|
||||
return vmRepo.CreateIso(&model.VMIso{
|
||||
Type: req.Type, Name: req.Name, Path: req.Path, Status: "ready", Description: req.Description,
|
||||
})
|
||||
}
|
||||
|
||||
func (v *VMService) StartIsoImport(req dto.VMIsoImportStart) (uint, error) {
|
||||
isoItem, _ := vmRepo.GetIso(repo.WithByName(req.Name))
|
||||
if isoItem.ID != 0 {
|
||||
return 0, buserr.New("ErrRecordExist")
|
||||
}
|
||||
item := model.VMIso{Type: req.Type, Name: req.Name, Path: req.Path, Status: req.Status, TempFileID: req.TempFileID, TempFileType: req.TempFileType, Description: req.Description}
|
||||
if err := vmRepo.CreateIso(&item); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return item.ID, nil
|
||||
}
|
||||
|
||||
func (v *VMService) UpdateIsoImport(req dto.VMIsoImportUpdate) error {
|
||||
isoItem, err := vmRepo.GetIso(repo.WithByID(req.ID))
|
||||
if err != nil || isoItem.ID == 0 {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
if isoItem.Status != "uploading" && isoItem.Status != "downloading" {
|
||||
return buserr.WithDetail("ErrVMInvalidInput", "ISO import is not active", nil)
|
||||
}
|
||||
if (isoItem.Status == "uploading" && req.Status == "downloading") ||
|
||||
(isoItem.Status == "downloading" && req.Status == "uploading") {
|
||||
return buserr.WithDetail("ErrVMInvalidInput", "ISO import type cannot be changed", nil)
|
||||
}
|
||||
vars := map[string]interface{}{"status": req.Status, "error": req.Error}
|
||||
if req.TempFileID != "" {
|
||||
vars["temp_file_id"] = req.TempFileID
|
||||
}
|
||||
if req.TempFileType != "" {
|
||||
vars["temp_file_type"] = req.TempFileType
|
||||
}
|
||||
if req.Status == "ready" {
|
||||
exists, _ := loadVMResourceFileInfo(isoItem.Path)
|
||||
if !exists {
|
||||
return buserr.WithDetail("ErrResourceNotExists", "Resource file does not exist", nil)
|
||||
}
|
||||
vars["error"] = ""
|
||||
}
|
||||
if req.Status == "ready" {
|
||||
vars["temp_file_id"] = ""
|
||||
vars["temp_file_type"] = ""
|
||||
}
|
||||
return vmRepo.UpdateIso(req.ID, vars)
|
||||
}
|
||||
|
||||
func (v *VMService) UpdateIso(req dto.VMIsoUpdate) error {
|
||||
isoItem, err := vmRepo.GetIso(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isoItem.ID == 0 {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
exists, _ := loadVMResourceFileInfo(req.Path)
|
||||
if !exists {
|
||||
return buserr.WithDetail("ErrResourceNotExists", "Resource file does not exist", nil)
|
||||
}
|
||||
return vmRepo.UpdateIso(req.ID, map[string]interface{}{
|
||||
"name": req.Name, "type": req.Type, "path": req.Path, "description": req.Description,
|
||||
})
|
||||
}
|
||||
|
||||
func (v *VMService) DeleteIso(req dto.VMIsoDelete) error {
|
||||
isoItem, err := vmRepo.GetIso(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if isoItem.ID == 0 {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
if req.DeleteFile && isoItem.Path != "" {
|
||||
exists, _ := loadVMResourceFileInfo(isoItem.Path)
|
||||
if exists {
|
||||
if err := os.Remove(isoItem.Path); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if isoItem.TempFileType == "upload" && validTempFileID(isoItem.TempFileID) {
|
||||
tmpPath := filepath.Join(global.CONF.Base.InstallDir, "1panel", "tmp", "upload", isoItem.TempFileID)
|
||||
if err := os.RemoveAll(tmpPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return vmRepo.DeleteIso(repo.WithByID(req.ID))
|
||||
}
|
||||
|
||||
func loadVMResourceFileInfo(filePath string) (bool, int64) {
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil || info.IsDir() {
|
||||
return false, 0
|
||||
}
|
||||
return true, info.Size()
|
||||
}
|
||||
|
||||
func validTempFileID(value string) bool {
|
||||
return value != "" && filepath.Base(value) == value && !strings.ContainsAny(value, `/\\`)
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (v *VMService) ListNetworks() ([]dto.VMNetworkItem, error) {
|
||||
records, err := vmRepo.ListNetworks()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := v.networkRecordsToItems(records)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (v *VMService) ListNetworkBridges() ([]dto.VMNetworkBridgeItem, error) {
|
||||
return listNetworkBridges("/sys/class/net")
|
||||
}
|
||||
|
||||
func (v *VMService) ListNetworkInterfaces() ([]dto.VMNetworkInterfaceItem, error) {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return listMacvtapInterfaces("/sys/class/net", interfaces)
|
||||
}
|
||||
|
||||
func (v *VMService) CreateNetwork(req dto.VMNetworkCreate) error {
|
||||
if req.Type == "macvtap" {
|
||||
interfaces, err := v.ListNetworkInterfaces()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, item := range interfaces {
|
||||
if item.Name == req.SourceInterface {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("unavailable macvtap source interface: %s", req.SourceInterface)
|
||||
}
|
||||
} else {
|
||||
req.SourceInterface = ""
|
||||
}
|
||||
if req.Type != "nat" {
|
||||
req.CIDR, req.Gateway, req.DHCPStart, req.DHCPEnd = "", "", "", ""
|
||||
}
|
||||
if req.Type == "macvtap" {
|
||||
req.BridgeName = ""
|
||||
}
|
||||
ctx, cancel := vmResourceOperationContext(false)
|
||||
defer cancel()
|
||||
unlock, err := vmResourceLocks.lock(ctx, "network", req.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
itemNetwork, err := vmRepo.GetNetwork(repo.WithByName(req.Name))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if itemNetwork.ID != 0 {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
if req.Type == "bridge" && isManagedNetworkBridge(req.BridgeName) {
|
||||
return buserr.New("ErrVMBridgeManaged")
|
||||
}
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
if _, exists, err := v.findVMResourceWithContext(ctx, "network", req.Name); err != nil {
|
||||
return err
|
||||
} else if exists {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
record := model.VMNetwork{
|
||||
Name: req.Name, Type: req.Type, BridgeName: req.BridgeName, SourceInterface: req.SourceInterface,
|
||||
CIDR: req.CIDR, Gateway: req.Gateway, DHCPStart: req.DHCPStart, DHCPEnd: req.DHCPEnd,
|
||||
AutoStart: req.AutoStart, Status: vmResourceCreating, Description: req.Description,
|
||||
}
|
||||
if err := vmRepo.CreateNetwork(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.provider.CreateResource(ctx, types.VMResourceCreate{
|
||||
Type: "network", Name: req.Name, SubType: req.Type, BridgeName: req.BridgeName, SourceInterface: req.SourceInterface,
|
||||
CIDR: req.CIDR, Gateway: req.Gateway, DHCPStart: req.DHCPStart, DHCPEnd: req.DHCPEnd,
|
||||
AutoStart: req.AutoStart,
|
||||
}); err != nil {
|
||||
if updateErr := vmRepo.UpdateNetwork(record.ID, map[string]interface{}{
|
||||
"status": constant.StatusFailed, "error": err.Error(),
|
||||
}); updateErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("save network creation failure: %w", updateErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return vmRepo.UpdateNetwork(record.ID, map[string]interface{}{
|
||||
"status": constant.StatusRunning, "error": "",
|
||||
})
|
||||
}
|
||||
|
||||
func (v *VMService) SyncNetworks() error {
|
||||
revision, idle := vmResourceLocks.snapshot("network")
|
||||
if !idle {
|
||||
return nil
|
||||
}
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
liveItems, err := v.provider.ListResources(context.Background(), "network")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vmResourceLocks.applySnapshot("network", revision, func() error {
|
||||
records, err := vmRepo.ListNetworks()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liveByName := map[string]dto.VMResourceInfo{}
|
||||
for _, live := range liveItems {
|
||||
if live.Name == "" {
|
||||
continue
|
||||
}
|
||||
liveByName[live.Name] = live
|
||||
}
|
||||
recordsByName := map[string]model.VMNetwork{}
|
||||
for _, record := range records {
|
||||
if vmResourceStatusEquals(record.Status, vmResourceCreating) {
|
||||
record.Status = constant.StatusFailed
|
||||
record.Error = "Network creation was interrupted before its result was saved"
|
||||
if err := vmRepo.UpdateNetwork(record.ID, map[string]interface{}{
|
||||
"status": record.Status, "error": record.Error,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
recordsByName[record.Name] = record
|
||||
}
|
||||
for name, live := range liveByName {
|
||||
vars := map[string]interface{}{
|
||||
"type": live.SubType,
|
||||
"bridge_name": live.BridgeName,
|
||||
"source_interface": live.SourceInterface,
|
||||
"cidr": live.CIDR,
|
||||
"gateway": live.Gateway,
|
||||
"dhcp_start": live.DHCPStart,
|
||||
"dhcp_end": live.DHCPEnd,
|
||||
"auto_start": live.AutoStart,
|
||||
"status": normalizeVMResourceStatus(live.Status),
|
||||
}
|
||||
if record, ok := recordsByName[name]; ok {
|
||||
if vmResourceStatusEquals(record.Status, constant.StatusFailed) {
|
||||
continue
|
||||
}
|
||||
if err := vmRepo.UpdateNetwork(record.ID, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := vmRepo.CreateNetwork(&model.VMNetwork{
|
||||
Name: live.Name, Type: live.SubType, BridgeName: live.BridgeName, SourceInterface: live.SourceInterface,
|
||||
CIDR: live.CIDR, Gateway: live.Gateway, DHCPStart: live.DHCPStart, DHCPEnd: live.DHCPEnd,
|
||||
AutoStart: live.AutoStart, Status: normalizeVMResourceStatus(live.Status),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, record := range recordsByName {
|
||||
if vmResourceStatusEquals(record.Status, constant.StatusFailed) {
|
||||
continue
|
||||
}
|
||||
if _, ok := liveByName[record.Name]; !ok {
|
||||
if err := vmRepo.UpdateNetwork(record.ID, map[string]interface{}{
|
||||
"status": constant.StatusDeleted,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (v *VMService) UpdateNetwork(req dto.VMNetworkUpdate) error {
|
||||
ctx, cancel := vmResourceOperationContext(false)
|
||||
defer cancel()
|
||||
itemNetwork, err := vmRepo.GetNetwork(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unlock, err := vmResourceLocks.lock(ctx, "network", itemNetwork.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
itemNetwork, err = vmRepo.GetNetwork(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if itemNetwork.ID == 0 {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
vars := map[string]interface{}{}
|
||||
if req.Description != nil {
|
||||
vars["description"] = *req.Description
|
||||
}
|
||||
if req.AutoStart != nil || req.Status != nil {
|
||||
if vmResourceCreationIncomplete(itemNetwork.Status) {
|
||||
return buserr.WithDetail("ErrInvalidParams", "network creation has not succeeded", nil)
|
||||
}
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
live, ok, err := v.findVMResourceWithContext(ctx, "network", itemNetwork.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
_ = vmRepo.UpdateNetwork(req.ID, map[string]interface{}{"status": constant.StatusDeleted})
|
||||
return buserr.WithDetail("ErrResourceNotExists", "Network does not exist", nil)
|
||||
}
|
||||
if live.Status != "" {
|
||||
vars["status"] = normalizeVMResourceStatus(live.Status)
|
||||
}
|
||||
}
|
||||
if req.AutoStart != nil {
|
||||
if err := v.provider.UpdateResource(ctx, types.VMResourceUpdate{
|
||||
Type: "network", Name: itemNetwork.Name, AutoStart: req.AutoStart,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
vars["auto_start"] = *req.AutoStart
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := normalizeVMResourceStatus(*req.Status)
|
||||
if !validVMResourceOperateStatus(status) {
|
||||
return buserr.WithDetail("ErrInvalidParams", status, nil)
|
||||
}
|
||||
if err := v.provider.UpdateResource(ctx, types.VMResourceUpdate{
|
||||
Type: "network", Name: itemNetwork.Name, Status: &status,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
vars["status"] = status
|
||||
}
|
||||
if len(vars) == 0 {
|
||||
return nil
|
||||
}
|
||||
return vmRepo.UpdateNetwork(req.ID, vars)
|
||||
}
|
||||
|
||||
func (v *VMService) DeleteNetwork(req dto.VMResourceDelete) error {
|
||||
ctx, cancel := vmResourceOperationContext(req.Force)
|
||||
defer cancel()
|
||||
itemNetwork, err := vmRepo.GetNetwork(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
return err
|
||||
}
|
||||
unlock, cleanup, err := vmResourceLocks.lockForDelete(ctx, "network", itemNetwork.Name, req.Force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
itemNetwork, err = vmRepo.GetNetwork(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(loadVMResourceRefs("network")[itemNetwork.Name]) > 0 {
|
||||
return buserr.New("ErrVMNetworkInUse")
|
||||
}
|
||||
if cleanup {
|
||||
if err := v.deleteVMResource(ctx, "network", itemNetwork.Name, req.Force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return vmRepo.DeleteNetwork(repo.WithByID(req.ID))
|
||||
}
|
||||
|
||||
func (v *VMService) networkRecordsToItems(records []model.VMNetwork) []dto.VMNetworkItem {
|
||||
items := make([]dto.VMNetworkItem, 0, len(records))
|
||||
vmRefs := loadVMResourceRefs("network")
|
||||
usedIPs := v.loadVMNetworkIPUsages(records)
|
||||
for _, record := range records {
|
||||
status := normalizeVMResourceStatus(record.Status)
|
||||
items = append(items, dto.VMNetworkItem{
|
||||
ID: record.ID, Name: record.Name, Type: record.Type,
|
||||
BridgeName: record.BridgeName, SourceInterface: record.SourceInterface, CIDR: record.CIDR, Gateway: record.Gateway,
|
||||
DHCPStart: record.DHCPStart, DHCPEnd: record.DHCPEnd, AutoStart: record.AutoStart,
|
||||
Description: record.Description, CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
Status: status, Error: record.Error, VMCount: len(vmRefs[record.Name]), VMs: vmRefs[record.Name],
|
||||
Registered: true, UsedIPs: usedIPs[record.Name],
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func (v *VMService) loadVMNetworkIPUsages(records []model.VMNetwork) map[string][]dto.VMNetworkIPUsage {
|
||||
usages := map[string][]dto.VMNetworkIPUsage{}
|
||||
if !v.helperExists() {
|
||||
return usages
|
||||
}
|
||||
domains, err := v.provider.List(context.Background())
|
||||
if err != nil {
|
||||
return usages
|
||||
}
|
||||
vmRecords, err := vmRepo.List()
|
||||
if err != nil {
|
||||
return usages
|
||||
}
|
||||
vmRecordsByName := map[string]model.VirtualMachine{}
|
||||
for _, record := range vmRecords {
|
||||
vmRecordsByName[record.Name] = record
|
||||
}
|
||||
networkNames := map[string]struct{}{}
|
||||
bridgeToNetwork := map[string]string{}
|
||||
for _, record := range records {
|
||||
networkNames[record.Name] = struct{}{}
|
||||
if record.BridgeName != "" {
|
||||
bridgeToNetwork[record.BridgeName] = record.Name
|
||||
}
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if record, ok := vmRecordsByName[domain.Name]; ok {
|
||||
mergeVMMetadata(&domain, record)
|
||||
}
|
||||
for _, item := range domain.Interfaces {
|
||||
ip := strings.TrimSpace(item.IP)
|
||||
if ip == "" {
|
||||
continue
|
||||
}
|
||||
networkName := resolveVMInterfaceNetwork(item.Source, domain.Network, networkNames, bridgeToNetwork)
|
||||
if networkName == "" {
|
||||
continue
|
||||
}
|
||||
usages[networkName] = append(usages[networkName], dto.VMNetworkIPUsage{
|
||||
IP: ip, VMName: domain.Name, MAC: item.MAC, Source: item.Source,
|
||||
})
|
||||
}
|
||||
}
|
||||
for name := range usages {
|
||||
sort.Slice(usages[name], func(i, j int) bool {
|
||||
if usages[name][i].IP != usages[name][j].IP {
|
||||
return usages[name][i].IP < usages[name][j].IP
|
||||
}
|
||||
return strings.ToLower(usages[name][i].VMName) < strings.ToLower(usages[name][j].VMName)
|
||||
})
|
||||
}
|
||||
return usages
|
||||
}
|
||||
|
||||
func resolveVMInterfaceNetwork(source, vmNetwork string, networkNames map[string]struct{}, bridgeToNetwork map[string]string) string {
|
||||
if _, ok := networkNames[source]; ok {
|
||||
return source
|
||||
}
|
||||
if _, ok := networkNames[vmNetwork]; ok {
|
||||
return vmNetwork
|
||||
}
|
||||
return bridgeToNetwork[source]
|
||||
}
|
||||
|
||||
func listNetworkBridges(netClassPath string) ([]dto.VMNetworkBridgeItem, error) {
|
||||
entries, err := os.ReadDir(netClassPath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return []dto.VMNetworkBridgeItem{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
items := make([]dto.VMNetworkBridgeItem, 0)
|
||||
for _, entry := range entries {
|
||||
name := entry.Name()
|
||||
netPath := netClassPath + "/" + name
|
||||
info, err := os.Stat(netPath)
|
||||
if err != nil || !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(netPath + "/bridge"); err != nil {
|
||||
continue
|
||||
}
|
||||
item := dto.VMNetworkBridgeItem{
|
||||
Name: name,
|
||||
Recommended: true,
|
||||
}
|
||||
switch {
|
||||
case name == "docker0" || strings.HasPrefix(name, "br-"):
|
||||
item.Recommended = false
|
||||
item.Description = "docker"
|
||||
case name == "virbr0" || strings.HasPrefix(name, "virbr"):
|
||||
item.Recommended = false
|
||||
item.Description = "libvirt"
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Recommended != items[j].Recommended {
|
||||
return items[i].Recommended
|
||||
}
|
||||
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||
})
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func isManagedNetworkBridge(name string) bool {
|
||||
return name == "docker0" || strings.HasPrefix(name, "br-") || name == "virbr0" || strings.HasPrefix(name, "virbr")
|
||||
}
|
||||
|
||||
func listMacvtapInterfaces(netClassPath string, interfaces []net.Interface) ([]dto.VMNetworkInterfaceItem, error) {
|
||||
items := make([]dto.VMNetworkInterfaceItem, 0)
|
||||
for _, iface := range interfaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 || len(iface.HardwareAddr) != 6 {
|
||||
continue
|
||||
}
|
||||
netPath := filepath.Join(netClassPath, iface.Name)
|
||||
if _, err := os.Stat(filepath.Join(netPath, "device")); err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
excluded := false
|
||||
for _, name := range []string{"master", "wireless", "bridge"} {
|
||||
if _, err := os.Stat(filepath.Join(netPath, name)); err == nil {
|
||||
excluded = true
|
||||
break
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if excluded {
|
||||
continue
|
||||
}
|
||||
addresses, err := iface.Addrs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item := dto.VMNetworkInterfaceItem{Name: iface.Name, Addresses: []string{}}
|
||||
for _, address := range addresses {
|
||||
item.Addresses = append(item.Addresses, address.String())
|
||||
}
|
||||
items = append(items, item)
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name })
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
)
|
||||
|
||||
func (v *VMService) deleteVMResource(ctx context.Context, resourceType, name string, force bool) error {
|
||||
if err := v.deleteVMResourceWithContext(ctx, resourceType, name); err != nil {
|
||||
if !force {
|
||||
return err
|
||||
}
|
||||
if global.LOG != nil {
|
||||
global.LOG.Warnf("[xpack] force delete VM %s %q metadata after resource cleanup failed: %v", resourceType, name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *VMService) deleteVMResourceWithContext(ctx context.Context, resourceType, name string) error {
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
items, err := v.provider.ListResources(ctx, resourceType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Name == name {
|
||||
return v.provider.DeleteResource(ctx, resourceType, name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
)
|
||||
|
||||
type vmResourceKey struct{ kind, name string }
|
||||
type vmResourceGate struct {
|
||||
token chan struct{}
|
||||
refs int
|
||||
}
|
||||
|
||||
type vmResourceLockSet struct {
|
||||
mu sync.Mutex
|
||||
gates map[vmResourceKey]*vmResourceGate
|
||||
active map[string]int
|
||||
revision map[string]uint64
|
||||
}
|
||||
|
||||
var vmResourceLocks = newVMResourceLockSet()
|
||||
|
||||
func newVMResourceLockSet() *vmResourceLockSet {
|
||||
return &vmResourceLockSet{gates: make(map[vmResourceKey]*vmResourceGate), active: make(map[string]int), revision: make(map[string]uint64)}
|
||||
}
|
||||
func vmResourceOperationContext(force bool) (context.Context, context.CancelFunc) {
|
||||
timeout := 2 * time.Minute
|
||||
if force {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
return context.WithTimeout(context.Background(), timeout)
|
||||
}
|
||||
func (s *vmResourceLockSet) lock(ctx context.Context, kind, name string) (func(), error) {
|
||||
key := vmResourceKey{kind, name}
|
||||
s.mu.Lock()
|
||||
gate := s.gates[key]
|
||||
if gate == nil {
|
||||
gate = &vmResourceGate{token: make(chan struct{}, 1)}
|
||||
gate.token <- struct{}{}
|
||||
s.gates[key] = gate
|
||||
}
|
||||
gate.refs++
|
||||
s.mu.Unlock()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.mu.Lock()
|
||||
s.dropRef(key, gate)
|
||||
s.mu.Unlock()
|
||||
return nil, ctx.Err()
|
||||
case <-gate.token:
|
||||
}
|
||||
s.mu.Lock()
|
||||
if err := ctx.Err(); err != nil {
|
||||
gate.token <- struct{}{}
|
||||
s.dropRef(key, gate)
|
||||
s.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
s.active[kind]++
|
||||
s.revision[kind]++
|
||||
s.mu.Unlock()
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
s.active[kind]--
|
||||
s.revision[kind]++
|
||||
gate.token <- struct{}{}
|
||||
s.dropRef(key, gate)
|
||||
s.mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
func (s *vmResourceLockSet) dropRef(key vmResourceKey, gate *vmResourceGate) {
|
||||
gate.refs--
|
||||
if gate.refs == 0 {
|
||||
delete(s.gates, key)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *vmResourceLockSet) lockForDelete(ctx context.Context, kind, name string, force bool) (func(), bool, error) {
|
||||
unlock, err := s.lock(ctx, kind, name)
|
||||
if err == nil {
|
||||
return unlock, true, nil
|
||||
}
|
||||
if !force {
|
||||
return nil, false, err
|
||||
}
|
||||
if global.LOG != nil {
|
||||
global.LOG.Warnf("[xpack] force delete VM %s %q metadata after waiting for resource lock failed: %v", kind, name, err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.revision[kind]++
|
||||
return func() { s.revision[kind]++; s.mu.Unlock() }, false, nil
|
||||
}
|
||||
|
||||
func (s *vmResourceLockSet) snapshot(kind string) (uint64, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.revision[kind], s.active[kind] == 0
|
||||
}
|
||||
|
||||
func (s *vmResourceLockSet) applySnapshot(kind string, revision uint64, apply func() error) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.active[kind] != 0 || s.revision[kind] != revision {
|
||||
return nil
|
||||
}
|
||||
defer func() { s.revision[kind]++ }()
|
||||
return apply()
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
)
|
||||
|
||||
const vmResourceCreating = "creating"
|
||||
|
||||
func vmResourceCreationIncomplete(status string) bool {
|
||||
return vmResourceStatusEquals(status, vmResourceCreating) || vmResourceStatusEquals(status, constant.StatusFailed)
|
||||
}
|
||||
|
||||
func normalizeVMResourceStatus(status string) string {
|
||||
status = strings.TrimSpace(status)
|
||||
if status == "" {
|
||||
return constant.StatusUnknown
|
||||
}
|
||||
switch strings.ToLower(status) {
|
||||
case vmResourceCreating:
|
||||
return vmResourceCreating
|
||||
case strings.ToLower(constant.StatusFailed):
|
||||
return constant.StatusFailed
|
||||
case strings.ToLower(constant.StatusRunning):
|
||||
return constant.StatusRunning
|
||||
case strings.ToLower(constant.StatusInactive):
|
||||
return constant.StatusInactive
|
||||
case strings.ToLower(constant.StatusDeleted):
|
||||
return constant.StatusDeleted
|
||||
case strings.ToLower(constant.StatusUnknown):
|
||||
return constant.StatusUnknown
|
||||
case strings.ToLower(constant.StatusBuilding):
|
||||
return constant.StatusBuilding
|
||||
case strings.ToLower(constant.StatusDegraded):
|
||||
return constant.StatusDegraded
|
||||
case strings.ToLower(constant.StatusInaccessible):
|
||||
return constant.StatusInaccessible
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
func vmResourceStatusEquals(status string, target string) bool {
|
||||
return strings.EqualFold(strings.TrimSpace(status), target)
|
||||
}
|
||||
|
||||
func vmResourceExists(status string) bool {
|
||||
status = normalizeVMResourceStatus(status)
|
||||
return !vmResourceStatusEquals(status, constant.StatusDeleted)
|
||||
}
|
||||
|
||||
func validVMResourceOperateStatus(status string) bool {
|
||||
return vmResourceStatusEquals(status, constant.StatusRunning) || vmResourceStatusEquals(status, constant.StatusInactive)
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/app/repo"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/constant"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/types"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func (v *VMService) ListStorages() ([]dto.VMStorageItem, error) {
|
||||
records, err := vmRepo.ListStorages()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := v.mergeStorageItems(records)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (v *VMService) CreateStorage(req dto.VMStorageCreate) error {
|
||||
ctx, cancel := vmResourceOperationContext(false)
|
||||
defer cancel()
|
||||
unlock, err := vmResourceLocks.lock(ctx, "storagePool", req.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
req.Path = strings.TrimSpace(req.Path)
|
||||
unlockPath, err := vmResourceLocks.lock(ctx, "storagePath", normalizeStoragePath(req.Path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlockPath()
|
||||
itemStorage, err := vmRepo.GetStorage(repo.WithByName(req.Name))
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if itemStorage.ID != 0 {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
records, err := vmRepo.ListStorages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, record := range records {
|
||||
if sameStoragePath(record.Path, req.Path) {
|
||||
return buserr.WithName("ErrVMStoragePathInUse", record.Name)
|
||||
}
|
||||
}
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
liveItems, err := v.provider.ListResources(ctx, "storagePool")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, live := range liveItems {
|
||||
if live.Name == req.Name {
|
||||
return buserr.New("ErrRecordExist")
|
||||
}
|
||||
if sameStoragePath(live.Path, req.Path) {
|
||||
return buserr.WithName("ErrVMStoragePathInUse", live.Name)
|
||||
}
|
||||
}
|
||||
record := model.VMStorage{
|
||||
Name: req.Name, Type: req.Type, Path: req.Path,
|
||||
AutoStart: req.AutoStart, Status: vmResourceCreating, Description: req.Description,
|
||||
}
|
||||
if err := vmRepo.CreateStorage(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := v.provider.CreateResource(ctx, types.VMResourceCreate{
|
||||
Type: "storagePool", Name: req.Name, SubType: req.Type, Path: req.Path, AutoStart: req.AutoStart,
|
||||
}); err != nil {
|
||||
if updateErr := vmRepo.UpdateStorage(record.ID, map[string]interface{}{
|
||||
"status": constant.StatusFailed, "error": err.Error(),
|
||||
}); updateErr != nil {
|
||||
return errors.Join(err, fmt.Errorf("save storage pool creation failure: %w", updateErr))
|
||||
}
|
||||
return err
|
||||
}
|
||||
return vmRepo.UpdateStorage(record.ID, map[string]interface{}{
|
||||
"status": constant.StatusRunning, "error": "",
|
||||
})
|
||||
}
|
||||
|
||||
func sameStoragePath(left, right string) bool {
|
||||
left = normalizeStoragePath(left)
|
||||
right = normalizeStoragePath(right)
|
||||
return left != "" && left == right
|
||||
}
|
||||
|
||||
func normalizeStoragePath(path string) string {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return ""
|
||||
}
|
||||
return filepath.Clean(path)
|
||||
}
|
||||
|
||||
func (v *VMService) SyncStorages() error {
|
||||
revision, idle := vmResourceLocks.snapshot("storagePool")
|
||||
if !idle {
|
||||
return nil
|
||||
}
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
liveItems, err := v.provider.ListResources(context.Background(), "storagePool")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return vmResourceLocks.applySnapshot("storagePool", revision, func() error {
|
||||
records, err := vmRepo.ListStorages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
liveByName := map[string]dto.VMResourceInfo{}
|
||||
for _, live := range liveItems {
|
||||
if live.Name == "" {
|
||||
continue
|
||||
}
|
||||
liveByName[live.Name] = live
|
||||
}
|
||||
recordsByName := map[string]model.VMStorage{}
|
||||
for _, record := range records {
|
||||
if vmResourceStatusEquals(record.Status, vmResourceCreating) {
|
||||
record.Status = constant.StatusFailed
|
||||
record.Error = "Storage pool creation was interrupted before its result was saved"
|
||||
if err := vmRepo.UpdateStorage(record.ID, map[string]interface{}{
|
||||
"status": record.Status, "error": record.Error,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
recordsByName[record.Name] = record
|
||||
}
|
||||
for name, live := range liveByName {
|
||||
vars := map[string]interface{}{
|
||||
"type": live.SubType,
|
||||
"path": live.Path,
|
||||
"auto_start": live.AutoStart,
|
||||
"status": normalizeVMResourceStatus(live.Status),
|
||||
}
|
||||
if record, ok := recordsByName[name]; ok {
|
||||
if vmResourceStatusEquals(record.Status, constant.StatusFailed) {
|
||||
continue
|
||||
}
|
||||
if err := vmRepo.UpdateStorage(record.ID, vars); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := vmRepo.CreateStorage(&model.VMStorage{
|
||||
Name: live.Name, Type: live.SubType, Path: live.Path,
|
||||
AutoStart: live.AutoStart, Status: normalizeVMResourceStatus(live.Status),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, record := range recordsByName {
|
||||
if vmResourceStatusEquals(record.Status, constant.StatusFailed) {
|
||||
continue
|
||||
}
|
||||
if _, ok := liveByName[record.Name]; !ok {
|
||||
if err := vmRepo.UpdateStorage(record.ID, map[string]interface{}{
|
||||
"status": constant.StatusDeleted,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (v *VMService) UpdateStorage(req dto.VMStorageUpdate) error {
|
||||
ctx, cancel := vmResourceOperationContext(false)
|
||||
defer cancel()
|
||||
itemStorage, err := vmRepo.GetStorage(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
unlock, err := vmResourceLocks.lock(ctx, "storagePool", itemStorage.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
itemStorage, err = vmRepo.GetStorage(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if itemStorage.ID == 0 {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
vars := map[string]interface{}{}
|
||||
if req.Name != "" && req.Name != itemStorage.Name {
|
||||
return buserr.New("ErrVMStorageNameCannotChange")
|
||||
}
|
||||
if req.Path != "" && req.Path != itemStorage.Path {
|
||||
return buserr.New("ErrVMStoragePathCannotChange")
|
||||
}
|
||||
if req.Description != nil {
|
||||
vars["description"] = *req.Description
|
||||
}
|
||||
if req.AutoStart != nil || req.Status != nil {
|
||||
if vmResourceCreationIncomplete(itemStorage.Status) {
|
||||
return buserr.WithDetail("ErrInvalidParams", "storage pool creation has not succeeded", nil)
|
||||
}
|
||||
if !v.helperExists() {
|
||||
return buserr.New("ErrVMHelperNotFound")
|
||||
}
|
||||
live, ok, err := v.findVMResourceWithContext(ctx, "storagePool", itemStorage.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
_ = vmRepo.UpdateStorage(req.ID, map[string]interface{}{"status": constant.StatusDeleted})
|
||||
return buserr.WithDetail("ErrResourceNotExists", "Storage pool does not exist", nil)
|
||||
}
|
||||
if live.Status != "" {
|
||||
vars["status"] = normalizeVMResourceStatus(live.Status)
|
||||
}
|
||||
}
|
||||
if req.AutoStart != nil {
|
||||
if err := v.provider.UpdateResource(ctx, types.VMResourceUpdate{
|
||||
Type: "storagePool", Name: itemStorage.Name, AutoStart: req.AutoStart,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
vars["auto_start"] = *req.AutoStart
|
||||
}
|
||||
if req.Status != nil {
|
||||
status := normalizeVMResourceStatus(*req.Status)
|
||||
if !validVMResourceOperateStatus(status) {
|
||||
return buserr.WithDetail("ErrInvalidParams", status, nil)
|
||||
}
|
||||
if err := v.provider.UpdateResource(ctx, types.VMResourceUpdate{
|
||||
Type: "storagePool", Name: itemStorage.Name, Status: &status,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
vars["status"] = status
|
||||
}
|
||||
if len(vars) == 0 {
|
||||
return nil
|
||||
}
|
||||
return vmRepo.UpdateStorage(req.ID, vars)
|
||||
}
|
||||
|
||||
func (v *VMService) DeleteStorage(req dto.VMResourceDelete) error {
|
||||
ctx, cancel := vmResourceOperationContext(req.Force)
|
||||
defer cancel()
|
||||
itemStorage, err := vmRepo.GetStorage(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return buserr.New("ErrRecordNotFound")
|
||||
}
|
||||
return err
|
||||
}
|
||||
unlock, cleanup, err := vmResourceLocks.lockForDelete(ctx, "storagePool", itemStorage.Name, req.Force)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
itemStorage, err = vmRepo.GetStorage(repo.WithByID(req.ID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(loadVMResourceRefs("storagePool")[itemStorage.Name]) > 0 {
|
||||
return buserr.New("ErrVMStorageInUse")
|
||||
}
|
||||
if cleanup {
|
||||
if err := v.deleteVMResource(ctx, "storagePool", itemStorage.Name, req.Force); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return vmRepo.DeleteStorage(repo.WithByID(req.ID))
|
||||
}
|
||||
|
||||
func (v *VMService) mergeStorageItems(records []model.VMStorage) []dto.VMStorageItem {
|
||||
itemsByValue := map[string]dto.VMStorageItem{}
|
||||
for _, record := range records {
|
||||
itemsByValue[record.Name] = dto.VMStorageItem{
|
||||
ID: record.ID, Name: record.Name, Type: record.Type,
|
||||
Path: record.Path, AutoStart: record.AutoStart, Description: record.Description,
|
||||
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
Status: normalizeVMResourceStatus(record.Status),
|
||||
Error: record.Error,
|
||||
}
|
||||
}
|
||||
if v.helperExists() {
|
||||
if liveItems, err := v.provider.ListResources(context.Background(), "storagePool"); err == nil {
|
||||
for _, live := range liveItems {
|
||||
item, ok := itemsByValue[live.Name]
|
||||
if !ok || vmResourceCreationIncomplete(item.Status) {
|
||||
continue
|
||||
}
|
||||
item.Type = live.SubType
|
||||
item.Status = normalizeVMResourceStatus(live.Status)
|
||||
item.AutoStart = live.AutoStart
|
||||
item.Capacity = live.Capacity
|
||||
item.Available = live.Available
|
||||
itemsByValue[live.Name] = item
|
||||
}
|
||||
}
|
||||
}
|
||||
vmRefs := loadVMResourceRefs("storagePool")
|
||||
items := make([]dto.VMStorageItem, 0, len(records))
|
||||
for _, record := range records {
|
||||
item := itemsByValue[record.Name]
|
||||
if item.Status == "" {
|
||||
item.Status = constant.StatusDeleted
|
||||
}
|
||||
item.VMs = vmRefs[record.Name]
|
||||
item.VMCount = len(item.VMs)
|
||||
items = append(items, item)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func loadVMResourceRefs(resourceType string) map[string][]dto.VMResourceRef {
|
||||
records, err := vmRepo.List()
|
||||
if err != nil {
|
||||
return map[string][]dto.VMResourceRef{}
|
||||
}
|
||||
refs := map[string][]dto.VMResourceRef{}
|
||||
for _, record := range records {
|
||||
ref := dto.VMResourceRef{ID: record.ID, Name: record.Name}
|
||||
switch resourceType {
|
||||
case "network":
|
||||
if record.Network != "" {
|
||||
refs[record.Network] = append(refs[record.Network], ref)
|
||||
}
|
||||
case "storagePool":
|
||||
if record.StoragePool != "" {
|
||||
refs[record.StoragePool] = append(refs[record.StoragePool], ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
for name := range refs {
|
||||
sort.Slice(refs[name], func(i, j int) bool {
|
||||
return strings.ToLower(refs[name][i].Name) < strings.ToLower(refs[name][j].Name)
|
||||
})
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func (v *VMService) fillVMStoragePools(items []dto.VMInfo) {
|
||||
needsPools := false
|
||||
for _, item := range items {
|
||||
if item.StoragePool == "" && item.DiskPath != "" {
|
||||
needsPools = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !needsPools {
|
||||
return
|
||||
}
|
||||
pools, err := v.provider.ListResources(context.Background(), "storagePool")
|
||||
if err != nil {
|
||||
global.LOG.Warnf("[xpack] resolve VM storage pools failed, retaining metadata: %v", err)
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if items[i].StoragePool == "" {
|
||||
items[i].StoragePool = storagePoolForDisk(items[i].DiskPath, pools)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func storagePoolForDisk(diskPath string, pools []dto.VMResourceInfo) string {
|
||||
if !filepath.IsAbs(diskPath) {
|
||||
return ""
|
||||
}
|
||||
poolName, bestLength := "", 0
|
||||
for _, pool := range pools {
|
||||
poolPath := normalizeStoragePath(pool.Path)
|
||||
if pool.Name == "" || !filepath.IsAbs(poolPath) {
|
||||
continue
|
||||
}
|
||||
rel, err := filepath.Rel(poolPath, diskPath)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
continue
|
||||
}
|
||||
if len(poolPath) > bestLength {
|
||||
poolName, bestLength = pool.Name, len(poolPath)
|
||||
} else if len(poolPath) == bestLength && poolName != pool.Name {
|
||||
poolName = ""
|
||||
}
|
||||
}
|
||||
return poolName
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5220,7 +5220,7 @@
|
||||
"formatZH": "更新 vLLM [name]",
|
||||
"formatEN": "update vLLM [name]"
|
||||
},
|
||||
"/xpack/vms": {
|
||||
"/vms": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
@@ -5229,7 +5229,7 @@
|
||||
"formatZH": "创建虚拟机 [name]",
|
||||
"formatEN": "create VM [name]"
|
||||
},
|
||||
"/xpack/vms/del": {
|
||||
"/vms/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5247,7 +5247,7 @@
|
||||
"formatZH": "删除虚拟机 [name]",
|
||||
"formatEN": "delete VM [name]"
|
||||
},
|
||||
"/xpack/vms/disks/resize": {
|
||||
"/vms/disks/resize": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"target",
|
||||
@@ -5258,14 +5258,14 @@
|
||||
"formatZH": "扩容虚拟机 [name] 磁盘 [target] 至 [sizeGiB] GiB",
|
||||
"formatEN": "expand VM [name] disk [target] to [sizeGiB] GiB"
|
||||
},
|
||||
"/xpack/vms/environment/enable": {
|
||||
"/vms/environment/enable": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "启用虚拟机运行环境",
|
||||
"formatEN": "enable VM runtime environment"
|
||||
},
|
||||
"/xpack/vms/iso": {
|
||||
"/vms/iso": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5275,7 +5275,7 @@
|
||||
"formatZH": "创建虚拟机镜像 [name] [type]",
|
||||
"formatEN": "create VM ISO [name] [type]"
|
||||
},
|
||||
"/xpack/vms/iso/del": {
|
||||
"/vms/iso/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5293,7 +5293,7 @@
|
||||
"formatZH": "删除虚拟机镜像 [name]",
|
||||
"formatEN": "delete VM ISO [name]"
|
||||
},
|
||||
"/xpack/vms/iso/update": {
|
||||
"/vms/iso/update": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5303,7 +5303,7 @@
|
||||
"formatZH": "更新虚拟机镜像 [name] [type]",
|
||||
"formatEN": "update VM ISO [name] [type]"
|
||||
},
|
||||
"/xpack/vms/networks": {
|
||||
"/vms/networks": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5313,7 +5313,7 @@
|
||||
"formatZH": "创建虚拟机网络 [name] [type]",
|
||||
"formatEN": "create VM network [name] [type]"
|
||||
},
|
||||
"/xpack/vms/networks/del": {
|
||||
"/vms/networks/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5331,7 +5331,7 @@
|
||||
"formatZH": "删除虚拟机网络 [name]",
|
||||
"formatEN": "delete VM network [name]"
|
||||
},
|
||||
"/xpack/vms/networks/update": {
|
||||
"/vms/networks/update": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5349,7 +5349,7 @@
|
||||
"formatZH": "更新虚拟机网络 [name]",
|
||||
"formatEN": "update VM network [name]"
|
||||
},
|
||||
"/xpack/vms/operate": {
|
||||
"/vms/operate": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"operate"
|
||||
@@ -5359,14 +5359,14 @@
|
||||
"formatZH": "操作虚拟机 [name] [operate]",
|
||||
"formatEN": "operate VM [name] [operate]"
|
||||
},
|
||||
"/xpack/vms/orphans/clean": {
|
||||
"/vms/orphans/clean": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "清理虚拟机孤立数据",
|
||||
"formatEN": "clean VM orphaned data"
|
||||
},
|
||||
"/xpack/vms/rename": {
|
||||
"/vms/rename": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"newName"
|
||||
@@ -5376,7 +5376,7 @@
|
||||
"formatZH": "重命名虚拟机 [name] 为 [newName]",
|
||||
"formatEN": "rename VM [name] to [newName]"
|
||||
},
|
||||
"/xpack/vms/snapshots": {
|
||||
"/vms/snapshots": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"snapshotName"
|
||||
@@ -5386,7 +5386,7 @@
|
||||
"formatZH": "创建虚拟机 [name] 快照 [snapshotName]",
|
||||
"formatEN": "create VM [name] snapshot [snapshotName]"
|
||||
},
|
||||
"/xpack/vms/snapshots/del": {
|
||||
"/vms/snapshots/del": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"snapshotName"
|
||||
@@ -5396,7 +5396,7 @@
|
||||
"formatZH": "删除虚拟机 [name] 快照 [snapshotName]",
|
||||
"formatEN": "delete VM [name] snapshot [snapshotName]"
|
||||
},
|
||||
"/xpack/vms/snapshots/recover": {
|
||||
"/vms/snapshots/recover": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"snapshotName"
|
||||
@@ -5406,7 +5406,7 @@
|
||||
"formatZH": "恢复虚拟机 [name] 快照 [snapshotName]",
|
||||
"formatEN": "recover VM [name] snapshot [snapshotName]"
|
||||
},
|
||||
"/xpack/vms/storages": {
|
||||
"/vms/storages": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5416,7 +5416,7 @@
|
||||
"formatZH": "创建虚拟机存储 [name] [type]",
|
||||
"formatEN": "create VM storage [name] [type]"
|
||||
},
|
||||
"/xpack/vms/storages/del": {
|
||||
"/vms/storages/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5434,7 +5434,7 @@
|
||||
"formatZH": "删除虚拟机存储 [name]",
|
||||
"formatEN": "delete VM storage [name]"
|
||||
},
|
||||
"/xpack/vms/storages/update": {
|
||||
"/vms/storages/update": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5452,14 +5452,14 @@
|
||||
"formatZH": "更新虚拟机存储 [name]",
|
||||
"formatEN": "update VM storage [name]"
|
||||
},
|
||||
"/xpack/vms/sync": {
|
||||
"/vms/sync": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "同步虚拟机",
|
||||
"formatEN": "sync virtual machines"
|
||||
},
|
||||
"/xpack/vms/templates": {
|
||||
"/vms/templates": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"templateName"
|
||||
@@ -5469,7 +5469,7 @@
|
||||
"formatZH": "创建虚拟机 [name] 模板 [templateName]",
|
||||
"formatEN": "create VM [name] template [templateName]"
|
||||
},
|
||||
"/xpack/vms/templates/del": {
|
||||
"/vms/templates/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5487,7 +5487,7 @@
|
||||
"formatZH": "删除虚拟机模板 [name]",
|
||||
"formatEN": "delete VM template [name]"
|
||||
},
|
||||
"/xpack/vms/update": {
|
||||
"/vms/update": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "The SSH public and private keys do not match"
|
||||
# virtual machine
|
||||
ErrVMNotFound: "Virtual machine does not exist"
|
||||
ErrVMAlreadyExists: "Virtual machine already exists"
|
||||
ErrVMCountLimit: "The Professional edition supports up to {{ .limit }} virtual machines. Delete an existing VM or upgrade to Enterprise to create more."
|
||||
ErrVMCountLimit: "The current edition supports up to {{ .limit }} virtual machines. Delete an existing VM or upgrade your edition and try again."
|
||||
ErrVMImageAlreadyExists: "Virtual machine image already exists: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "Storage pool path is already used by [{{ .name }}]"
|
||||
ErrVMHelperNotFound: "1panel-kvm not found"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "Las claves pública y privada SSH no coinciden"
|
||||
# máquina virtual
|
||||
ErrVMNotFound: "La máquina virtual no existe"
|
||||
ErrVMAlreadyExists: "La máquina virtual ya existe"
|
||||
ErrVMCountLimit: "La edición Professional permite crear hasta {{ .limit }} máquinas virtuales. Elimina una máquina virtual existente o actualiza a Enterprise y vuelve a intentarlo."
|
||||
ErrVMCountLimit: "La edición actual permite crear hasta {{ .limit }} máquinas virtuales. Elimina una máquina virtual existente o cambia a una edición superior y vuelve a intentarlo."
|
||||
ErrVMImageAlreadyExists: "La imagen de la máquina virtual ya existe: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "La ruta del pool de almacenamiento ya está usada por [{{ .name }}]"
|
||||
ErrVMHelperNotFound: "No se encontró 1panel-kvm"
|
||||
|
||||
@@ -737,7 +737,7 @@ ErrSSHKeyMismatch: "کلید عمومی و خصوصی SSH مطابقت ندار
|
||||
# ماشین مجازی
|
||||
ErrVMNotFound: "ماشین مجازی وجود ندارد"
|
||||
ErrVMAlreadyExists: "ماشین مجازی از قبل وجود دارد"
|
||||
ErrVMCountLimit: "در نسخه Professional میتوانید حداکثر {{ .limit }} ماشین مجازی ایجاد کنید. یک ماشین مجازی موجود را حذف کنید یا به نسخه Enterprise ارتقا دهید و دوباره تلاش کنید."
|
||||
ErrVMCountLimit: "در نسخه فعلی میتوانید حداکثر {{ .limit }} ماشین مجازی ایجاد کنید. یک ماشین مجازی موجود را حذف کنید یا به نسخه بالاتر ارتقا دهید و دوباره تلاش کنید."
|
||||
ErrVMImageAlreadyExists: "فایل ایمیج ماشین مجازی از قبل وجود دارد: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "مسیر استخر ذخیرهسازی قبلاً توسط [{{ .name }}] استفاده شده است"
|
||||
ErrVMHelperNotFound: "1panel-kvm یافت نشد"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 公開鍵と秘密鍵が一致しません"
|
||||
# 仮想マシン
|
||||
ErrVMNotFound: "仮想マシンが存在しません"
|
||||
ErrVMAlreadyExists: "仮想マシンは既に存在します"
|
||||
ErrVMCountLimit: "Professional 版では最大 {{ .limit }} 台の仮想マシンを作成できます。既存の仮想マシンを削除するか、Enterprise 版にアップグレードしてから再試行してください。"
|
||||
ErrVMCountLimit: "現在のエディションでは最大 {{ .limit }} 台の仮想マシンを作成できます。 既存の仮想マシンを削除するか、上位エディションにアップグレードしてから再試行してください。"
|
||||
ErrVMImageAlreadyExists: "仮想マシンのイメージファイルは既に存在します: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "ストレージプールのパスは既に [{{ .name }}] で使用されています"
|
||||
ErrVMHelperNotFound: "1panel-kvm が見つかりません"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 공개 키와 개인 키가 일치하지 않습니다"
|
||||
# 가상 머신
|
||||
ErrVMNotFound: "가상 머신이 존재하지 않습니다"
|
||||
ErrVMAlreadyExists: "가상 머신이 이미 존재합니다"
|
||||
ErrVMCountLimit: "Professional 버전에서는 가상 머신을 최대 {{ .limit }}대까지 생성할 수 있습니다. 기존 가상 머신을 삭제하거나 Enterprise 버전으로 업그레이드한 후 다시 시도하세요."
|
||||
ErrVMCountLimit: "현재 버전에서는 가상 머신을 최대 {{ .limit }}대까지 생성할 수 있습니다. 기존 가상 머신을 삭제하거나 상위 버전으로 업그레이드한 후 다시 시도하세요."
|
||||
ErrVMImageAlreadyExists: "가상 머신 이미지 파일이 이미 존재합니다: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "스토리지 풀 경로가 이미 [{{ .name }}]에서 사용 중입니다"
|
||||
ErrVMHelperNotFound: "1panel-kvm을 찾을 수 없습니다"
|
||||
|
||||
@@ -727,7 +727,7 @@ ErrSSHKeyMismatch: "ຄີສາທາລະນະ ແລະ ຄີສ່ວນ
|
||||
# virtual machine
|
||||
ErrVMNotFound: "ບໍ່ມີເຄື່ອງສະເໝືອນ"
|
||||
ErrVMAlreadyExists: "ມີເຄື່ອງສະເໝືອນຢູ່ແລ້ວ"
|
||||
ErrVMCountLimit: "ລຸ້ນ Professional ສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {{ .limit }} ເຄື່ອງ. ກະລຸນາລຶບເຄື່ອງສະເໝືອນທີ່ມີຢູ່ ຫຼື ອັບເກຣດເປັນລຸ້ນ Enterprise ແລ້ວລອງອີກຄັ້ງ."
|
||||
ErrVMCountLimit: "ລຸ້ນປັດຈຸບັນສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {{ .limit }} ເຄື່ອງ. ກະລຸນາລຶບເຄື່ອງສະເໝືອນທີ່ມີຢູ່ ຫຼື ອັບເກຣດລຸ້ນ ແລ້ວລອງອີກຄັ້ງ."
|
||||
ErrVMImageAlreadyExists: "ມີອິມເມຈເຄື່ອງສະເໝືອນຢູ່ແລ້ວ: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "ເສັ້ນທາງພູນຈັດເກັບຖືກໃຊ້ໂດຍ [{{ .name }}] ແລ້ວ"
|
||||
ErrVMHelperNotFound: "ບໍ່ພົບ 1panel-kvm"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "Kunci awam dan peribadi SSH tidak sepadan"
|
||||
# mesin maya
|
||||
ErrVMNotFound: "Mesin maya tidak wujud"
|
||||
ErrVMAlreadyExists: "Mesin maya sudah wujud"
|
||||
ErrVMCountLimit: "Edisi Professional membenarkan penciptaan sehingga {{ .limit }} mesin maya. Padamkan mesin maya sedia ada atau naik taraf kepada Enterprise dan cuba lagi."
|
||||
ErrVMCountLimit: "Edisi semasa membenarkan penciptaan sehingga {{ .limit }} mesin maya. Padamkan mesin maya sedia ada atau naik taraf edisi anda dan cuba lagi."
|
||||
ErrVMImageAlreadyExists: "Fail imej mesin maya sudah wujud: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "Laluan kolam storan sudah digunakan oleh [{{ .name }}]"
|
||||
ErrVMHelperNotFound: "1panel-kvm tidak ditemui"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "As chaves pública e privada SSH não correspondem"
|
||||
# máquina virtual
|
||||
ErrVMNotFound: "Máquina virtual não existe"
|
||||
ErrVMAlreadyExists: "Máquina virtual já existe"
|
||||
ErrVMCountLimit: "A edição Professional permite criar até {{ .limit }} máquinas virtuais. Exclua uma máquina virtual existente ou atualize para Enterprise e tente novamente."
|
||||
ErrVMCountLimit: "A edição atual permite criar até {{ .limit }} máquinas virtuais. Exclua uma máquina virtual existente ou atualize para uma edição superior e tente novamente."
|
||||
ErrVMImageAlreadyExists: "A imagem da máquina virtual já existe: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "O caminho do pool de armazenamento já é usado por [{{ .name }}]"
|
||||
ErrVMHelperNotFound: "1panel-kvm não encontrado"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "Открытый и закрытый ключи SSH не с
|
||||
# виртуальная машина
|
||||
ErrVMNotFound: "Виртуальная машина не существует"
|
||||
ErrVMAlreadyExists: "Виртуальная машина уже существует"
|
||||
ErrVMCountLimit: "В редакции Professional можно создать не более {{ .limit }} виртуальных машин. Удалите существующую виртуальную машину или перейдите на Enterprise и повторите попытку."
|
||||
ErrVMCountLimit: "В текущей редакции можно создать не более {{ .limit }} виртуальных машин. Удалите существующую виртуальную машину или перейдите на старшую редакцию и повторите попытку."
|
||||
ErrVMImageAlreadyExists: "Файл образа виртуальной машины уже существует: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "Путь пула хранения уже используется [{{ .name }}]"
|
||||
ErrVMHelperNotFound: "1panel-kvm не найден"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH açık ve özel anahtarları eşleşmiyor"
|
||||
# sanal makine
|
||||
ErrVMNotFound: "Sanal makine mevcut değil"
|
||||
ErrVMAlreadyExists: "Sanal makine zaten mevcut"
|
||||
ErrVMCountLimit: "Professional sürümü en fazla {{ .limit }} sanal makine oluşturulmasına izin verir. Mevcut bir sanal makineyi silin veya Enterprise sürümüne yükseltip tekrar deneyin."
|
||||
ErrVMCountLimit: "Mevcut sürüm en fazla {{ .limit }} sanal makine oluşturulmasına izin verir. Mevcut bir sanal makineyi silin veya daha üst bir sürüme yükseltip tekrar deneyin."
|
||||
ErrVMImageAlreadyExists: "Sanal makine imaj dosyası zaten mevcut: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "Depolama havuzu yolu zaten [{{ .name }}] tarafından kullanılıyor"
|
||||
ErrVMHelperNotFound: "1panel-kvm bulunamadı"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 公鑰與私鑰不相符"
|
||||
# 虛擬機
|
||||
ErrVMNotFound: "虛擬機不存在"
|
||||
ErrVMAlreadyExists: "虛擬機已存在"
|
||||
ErrVMCountLimit: "專業版最多可建立 {{ .limit }} 台虛擬機,請刪除現有虛擬機或升級企業版後重試。"
|
||||
ErrVMCountLimit: "目前版本最多可建立 {{ .limit }} 台虛擬機。 請刪除現有虛擬機或升級版本後重試。"
|
||||
ErrVMImageAlreadyExists: "虛擬機映像檔已存在: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "儲存池路徑已被 [{{ .name }}] 使用"
|
||||
ErrVMHelperNotFound: "1panel-kvm 不存在"
|
||||
|
||||
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 公钥与私钥不匹配"
|
||||
# 虚拟机
|
||||
ErrVMNotFound: "虚拟机不存在"
|
||||
ErrVMAlreadyExists: "虚拟机已存在"
|
||||
ErrVMCountLimit: "专业版最多可创建 {{ .limit }} 台虚拟机,请删除已有虚拟机或升级企业版后重试。"
|
||||
ErrVMCountLimit: "当前版本最多可创建 {{ .limit }} 台虚拟机。 请删除已有虚拟机或升级版本后重试。"
|
||||
ErrVMImageAlreadyExists: "虚拟机镜像文件已存在: {{ .detail }}"
|
||||
ErrVMStoragePathInUse: "存储池路径已被 [{{ .name }}] 使用"
|
||||
ErrVMHelperNotFound: "1panel-kvm 不存在"
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package kvm
|
||||
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/app/service"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
vmHelper "github.com/1Panel-dev/1Panel/agent/utils/vm/helper"
|
||||
)
|
||||
|
||||
func Init() {
|
||||
if helperPath, embedded, err := vmHelper.EnsureEmbeddedHelper(); err != nil {
|
||||
global.LOG.Warnf("sync embedded kvm helper failed, err: %v", err)
|
||||
} else if embedded {
|
||||
global.LOG.Infof("embedded kvm helper synced: %s", helperPath)
|
||||
}
|
||||
if err := service.NewIVMService().SyncResourcesOnStartup(); err != nil {
|
||||
global.LOG.Warnf("sync VM resources on startup failed, err: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ func agentDBMigrations() []*gormigrate.Migration {
|
||||
migrations.AddContainerImageCheckUpdates,
|
||||
migrations.AddModelQuickJump,
|
||||
migrations.MigrateModelDownloader,
|
||||
migrations.MigrateVMDatabase,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/model"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/go-gormigrate/gormigrate/v2"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var MigrateVMDatabase = &gormigrate.Migration{
|
||||
ID: "20261102-migrate-vm-to-community",
|
||||
Migrate: func(db *gorm.DB) error {
|
||||
return migrateVMDatabase(db, global.Dir.DbDir)
|
||||
},
|
||||
}
|
||||
|
||||
func migrateVMDatabase(db *gorm.DB, dbDir string) error {
|
||||
var legacy *gorm.DB
|
||||
legacyPath := filepath.Join(dbDir, "xpack.db")
|
||||
if _, err := os.Stat(legacyPath); err == nil {
|
||||
uri := (&url.URL{Scheme: "file", Path: legacyPath, RawQuery: "mode=ro"}).String()
|
||||
legacy, err = gorm.Open(sqlite.Open(uri), &gorm.Config{DisableForeignKeyConstraintWhenMigrating: true})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open legacy VM database: %w", err)
|
||||
}
|
||||
sqlDB, err := legacy.DB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
} else if !os.IsNotExist(err) {
|
||||
return fmt.Errorf("inspect legacy VM database: %w", err)
|
||||
}
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.AutoMigrate(&model.VirtualMachine{}, &model.VMTemplate{}, &model.VMIso{}, &model.VMNetwork{}, &model.VMStorage{}); err != nil {
|
||||
return err
|
||||
}
|
||||
if legacy != nil {
|
||||
tables := []struct {
|
||||
name string
|
||||
records interface{}
|
||||
}{
|
||||
{name: "virtual_machines", records: &[]model.VirtualMachine{}},
|
||||
{name: "vm_templates", records: &[]model.VMTemplate{}},
|
||||
{name: "vm_isos", records: &[]model.VMIso{}},
|
||||
{name: "vm_networks", records: &[]model.VMNetwork{}},
|
||||
{name: "vm_storages", records: &[]model.VMStorage{}},
|
||||
}
|
||||
for _, table := range tables {
|
||||
if err := migrateVMTable(legacy, tx, table.name, table.records); err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", table.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func migrateVMTable(source, target *gorm.DB, table string, records interface{}) error {
|
||||
if !source.Migrator().HasTable(table) {
|
||||
return nil
|
||||
}
|
||||
if err := target.Table(table).Where("1 = 1").Delete(records).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
query := source.Table(table).Find(records)
|
||||
if query.Error != nil {
|
||||
return query.Error
|
||||
}
|
||||
if query.RowsAffected != 0 {
|
||||
if err := target.Table(table).CreateInBatches(records, 50).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
var migrated int64
|
||||
if err := target.Table(table).Count(&migrated).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if migrated != query.RowsAffected {
|
||||
return fmt.Errorf("expected %d records, migrated %d", query.RowsAffected, migrated)
|
||||
}
|
||||
}
|
||||
return migrateVMSequence(source, target, table)
|
||||
}
|
||||
|
||||
func migrateVMSequence(source, target *gorm.DB, table string) error {
|
||||
var sequenceTable int64
|
||||
if err := source.Raw("SELECT COUNT(*) FROM sqlite_master WHERE name = 'sqlite_sequence'").Scan(&sequenceTable).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if sequenceTable == 0 {
|
||||
return nil
|
||||
}
|
||||
var sequence int64
|
||||
if err := source.Raw("SELECT COALESCE(MAX(seq), 0) FROM sqlite_sequence WHERE name = ?", table).Scan(&sequence).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := target.Exec("INSERT INTO sqlite_sequence (name, seq) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM sqlite_sequence WHERE name = ?)", table, sequence, table).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return target.Exec("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?", sequence, table).Error
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
func commonGroups() []CommonRouter {
|
||||
return []CommonRouter{
|
||||
&VMRouter{},
|
||||
&DashboardRouter{},
|
||||
&HostRouter{},
|
||||
&ContainerRouter{},
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
v2 "github.com/1Panel-dev/1Panel/agent/app/api/v2"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type VMRouter struct{}
|
||||
|
||||
func (s *VMRouter) InitRouter(Router *gin.RouterGroup) {
|
||||
vmRouter := Router.Group("vms")
|
||||
baseApi := v2.ApiGroupApp.BaseApi
|
||||
{
|
||||
vmRouter.GET("/environment", baseApi.CheckVMEnvironment)
|
||||
vmRouter.POST("/environment/enable", baseApi.EnableVMEnvironment)
|
||||
vmRouter.POST("/search", baseApi.SearchVM)
|
||||
vmRouter.POST("/sync", baseApi.SyncVMs)
|
||||
vmRouter.POST("/info", baseApi.GetVM)
|
||||
vmRouter.POST("/health/check", baseApi.HealthCheckVM)
|
||||
vmRouter.POST("/stats", baseApi.GetVMStats)
|
||||
vmRouter.POST("", baseApi.CreateVM)
|
||||
vmRouter.POST("/templates/list", baseApi.ListVMTemplates)
|
||||
vmRouter.POST("/templates", baseApi.CreateVMTemplate)
|
||||
vmRouter.POST("/templates/del", baseApi.DeleteVMTemplate)
|
||||
vmRouter.POST("/update", baseApi.UpdateVM)
|
||||
vmRouter.POST("/disks/resize", baseApi.ResizeVMDisk)
|
||||
vmRouter.POST("/rename", baseApi.RenameVM)
|
||||
vmRouter.POST("/del", baseApi.DeleteVM)
|
||||
vmRouter.POST("/snapshots/list", baseApi.ListVMSnapshots)
|
||||
vmRouter.POST("/snapshots", baseApi.CreateVMSnapshot)
|
||||
vmRouter.POST("/snapshots/recover", baseApi.RecoverVMSnapshot)
|
||||
vmRouter.POST("/snapshots/del", baseApi.DeleteVMSnapshot)
|
||||
vmRouter.POST("/orphans/scan", baseApi.ScanVMOrphans)
|
||||
vmRouter.POST("/orphans/clean", baseApi.CleanVMOrphans)
|
||||
vmRouter.POST("/operate", baseApi.OperateVM)
|
||||
vmRouter.POST("/console", baseApi.GetVMConsole)
|
||||
vmRouter.GET("/console/ws", baseApi.ProxyVMConsole)
|
||||
|
||||
vmRouter.POST("/iso/search", baseApi.SearchVMIso)
|
||||
vmRouter.POST("/iso/list", baseApi.ListVMIso)
|
||||
vmRouter.POST("/iso", baseApi.CreateVMIso)
|
||||
vmRouter.POST("/iso/import/start", baseApi.StartVMIsoImport)
|
||||
vmRouter.POST("/iso/import/update", baseApi.UpdateVMIsoImport)
|
||||
vmRouter.POST("/iso/update", baseApi.UpdateVMIso)
|
||||
vmRouter.POST("/iso/del", baseApi.DeleteVMIso)
|
||||
|
||||
vmRouter.POST("/networks/list", baseApi.ListVMNetworks)
|
||||
vmRouter.GET("/networks/bridges", baseApi.ListVMNetworkBridges)
|
||||
vmRouter.GET("/networks/interfaces", baseApi.ListVMNetworkInterfaces)
|
||||
vmRouter.POST("/networks", baseApi.CreateVMNetwork)
|
||||
vmRouter.POST("/networks/update", baseApi.UpdateVMNetwork)
|
||||
vmRouter.POST("/networks/del", baseApi.DeleteVMNetwork)
|
||||
|
||||
vmRouter.POST("/storages/list", baseApi.ListVMStorages)
|
||||
vmRouter.POST("/storages", baseApi.CreateVMStorage)
|
||||
vmRouter.POST("/storages/update", baseApi.UpdateVMStorage)
|
||||
vmRouter.POST("/storages/del", baseApi.DeleteVMStorage)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"github.com/1Panel-dev/1Panel/agent/init/dir"
|
||||
"github.com/1Panel-dev/1Panel/agent/init/firewall"
|
||||
"github.com/1Panel-dev/1Panel/agent/init/hook"
|
||||
"github.com/1Panel-dev/1Panel/agent/init/kvm"
|
||||
"github.com/1Panel-dev/1Panel/agent/init/lang"
|
||||
"github.com/1Panel-dev/1Panel/agent/init/log"
|
||||
"github.com/1Panel-dev/1Panel/agent/init/migration"
|
||||
@@ -122,6 +123,7 @@ func Start() {
|
||||
global.LOG.Info("agent startup: firewall init scheduled")
|
||||
InitOthers()
|
||||
global.LOG.Info("agent startup: edition initialized")
|
||||
kvm.Init()
|
||||
|
||||
rootRouter := router.Routers()
|
||||
global.LOG.Info("agent startup: router initialized")
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/protocol"
|
||||
)
|
||||
|
||||
func EnsureEmbeddedHelper() (string, bool, error) {
|
||||
bin, ok := embeddedHelperBytes()
|
||||
if !ok {
|
||||
return "", false, nil
|
||||
}
|
||||
target, err := embeddedHelperPath()
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if sameFileContent(target, bin) {
|
||||
if err := os.Chmod(target, 0755); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
tmp := target + ".tmp"
|
||||
if err := os.WriteFile(tmp, bin, 0755); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if err := os.Rename(tmp, target); err != nil {
|
||||
_ = os.Remove(tmp)
|
||||
return "", true, err
|
||||
}
|
||||
if err := os.Chmod(target, 0755); err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
return target, true, nil
|
||||
}
|
||||
|
||||
func embeddedHelperPath() (string, error) {
|
||||
self, err := os.Executable()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load executable path: %w", err)
|
||||
}
|
||||
return filepath.Join(filepath.Dir(self), protocol.HelperBinary), nil
|
||||
}
|
||||
|
||||
func sameFileContent(path string, expected []byte) bool {
|
||||
current, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return sha256.Sum256(current) == sha256.Sum256(expected)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build amd64
|
||||
|
||||
package helper
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/1panel-kvm-amd64
|
||||
var embeddedHelper []byte
|
||||
|
||||
func embeddedHelperBytes() ([]byte, bool) {
|
||||
return embeddedHelper, len(embeddedHelper) > 0
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
//go:build arm64
|
||||
|
||||
package helper
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:embed bin/1panel-kvm-arm64
|
||||
var embeddedHelper []byte
|
||||
|
||||
func embeddedHelperBytes() ([]byte, bool) {
|
||||
return embeddedHelper, len(embeddedHelper) > 0
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !amd64 && !arm64
|
||||
|
||||
package helper
|
||||
|
||||
func embeddedHelperBytes() ([]byte, bool) {
|
||||
return nil, false
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
helper_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
mkdir -p "${helper_dir}/bin"
|
||||
|
||||
for arch in amd64 arm64; do
|
||||
target="${helper_dir}/bin/1panel-kvm-${arch}"
|
||||
if [ -s "${target}" ]; then
|
||||
continue
|
||||
fi
|
||||
download="${target}.download"
|
||||
trap 'rm -f "${download}"' EXIT
|
||||
curl --fail --location --retry 3 --connect-timeout 15 --max-time 300 \
|
||||
"https://resource.fit2cloud.com/1panel/resource/kvm/1panel-kvm-${arch}" \
|
||||
--output "${download}"
|
||||
test -s "${download}"
|
||||
chmod 755 "${download}"
|
||||
mv "${download}" "${target}"
|
||||
done
|
||||
@@ -0,0 +1,549 @@
|
||||
package helper
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/buserr"
|
||||
"github.com/1Panel-dev/1Panel/agent/global"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/protocol"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/types"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultTimeout = 2 * time.Minute
|
||||
longRunningTimeout = 30 * time.Minute
|
||||
slowActionThreshold = 5 * time.Second
|
||||
)
|
||||
|
||||
type Provider struct {
|
||||
HelperPath string
|
||||
URI string
|
||||
Timeout time.Duration
|
||||
mu sync.Mutex
|
||||
versionOnce sync.Once
|
||||
versionGate chan struct{}
|
||||
checked bool
|
||||
}
|
||||
|
||||
func NewProvider() (*Provider, error) {
|
||||
return &Provider{URI: protocol.DefaultURI, Timeout: defaultTimeout}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CheckEnvironment(ctx context.Context) ([]dto.VMCheckItem, error) {
|
||||
var items []protocol.VMCheckItem
|
||||
if err := p.call(ctx, protocol.ActionCheck, nil, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.VMCheckItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, dto.VMCheckItem{
|
||||
Name: item.Name,
|
||||
Passed: item.Passed,
|
||||
Message: item.Message,
|
||||
IsEnable: item.IsEnable,
|
||||
IsExist: item.IsExist,
|
||||
IsActive: item.IsActive,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) HealthCheck(ctx context.Context, name string) (dto.VMHealthCheckResult, error) {
|
||||
var result protocol.VMHealthCheckResult
|
||||
if err := p.callWithTimeout(
|
||||
ctx,
|
||||
protocol.ActionHealthCheck,
|
||||
protocol.VMHealthCheckRequest{Name: name},
|
||||
&result,
|
||||
longRunningTimeout,
|
||||
); err != nil {
|
||||
return dto.VMHealthCheckResult{}, err
|
||||
}
|
||||
out := dto.VMHealthCheckResult{
|
||||
Pass: result.Pass,
|
||||
Warn: result.Warn,
|
||||
Fail: result.Fail,
|
||||
Items: make([]dto.VMHealthCheckItem, 0, len(result.Items)),
|
||||
}
|
||||
for _, item := range result.Items {
|
||||
out.Items = append(out.Items, dto.VMHealthCheckItem{
|
||||
Level: item.Level, Check: item.Check, Reason: item.Reason, Params: item.Params,
|
||||
Detail: item.Detail, FixCommand: item.FixCommand,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) HelperStatus() bool {
|
||||
if _, err := p.resolveHelperPath(); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (p *Provider) List(ctx context.Context) ([]dto.VMInfo, error) {
|
||||
var items []protocol.VMInfo
|
||||
if err := p.call(ctx, protocol.ActionList, nil, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.VMInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, toDTO(item))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) ListResources(ctx context.Context, resourceType string) ([]dto.VMResourceInfo, error) {
|
||||
var items []protocol.VMResourceInfo
|
||||
if err := p.call(ctx, protocol.ActionResources, protocol.VMResourceSearch{Type: resourceType}, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.VMResourceInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, dto.VMResourceInfo{
|
||||
Type: item.Type, Name: item.Name, SubType: item.SubType, BridgeName: item.BridgeName, SourceInterface: item.SourceInterface,
|
||||
CIDR: item.CIDR, Gateway: item.Gateway, DHCPStart: item.DHCPStart, DHCPEnd: item.DHCPEnd,
|
||||
Path: item.Path, Status: item.Status, AutoStart: item.AutoStart, Capacity: item.Capacity, Available: item.Available,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateResource(ctx context.Context, req types.VMResourceCreate) error {
|
||||
return p.call(ctx, protocol.ActionCreateResource, protocol.VMResourceCreate{
|
||||
Type: req.Type, Name: req.Name, SubType: req.SubType, BridgeName: req.BridgeName, SourceInterface: req.SourceInterface,
|
||||
CIDR: req.CIDR, Gateway: req.Gateway, DHCPStart: req.DHCPStart, DHCPEnd: req.DHCPEnd,
|
||||
Path: req.Path, AutoStart: req.AutoStart,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) UpdateResource(ctx context.Context, req types.VMResourceUpdate) error {
|
||||
return p.call(ctx, protocol.ActionUpdateResource, protocol.VMResourceUpdate{
|
||||
Type: req.Type, Name: req.Name, AutoStart: req.AutoStart, Status: req.Status,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) DeleteResource(ctx context.Context, resourceType string, name string) error {
|
||||
return p.call(ctx, protocol.ActionDeleteResource, protocol.VMResourceDelete{Type: resourceType, Name: name}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) ScanOrphans(ctx context.Context, protectedPaths []string) ([]dto.VMOrphanItem, error) {
|
||||
var items []protocol.VMOrphanItem
|
||||
if err := p.callWithTimeout(ctx, protocol.ActionScanOrphans, protocol.VMOrphanScan{ProtectedPaths: protectedPaths}, &items, longRunningTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.VMOrphanItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, dto.VMOrphanItem{Type: item.Type, Path: item.Path, Size: item.Size, ModifiedAt: item.ModifiedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CleanOrphans(ctx context.Context, items []dto.VMOrphanItem, protectedPaths []string) ([]dto.VMOrphanItem, error) {
|
||||
payload := protocol.VMOrphanClean{ProtectedPaths: protectedPaths, Items: make([]protocol.VMOrphanItem, 0, len(items))}
|
||||
for _, item := range items {
|
||||
payload.Items = append(payload.Items, protocol.VMOrphanItem{Type: item.Type, Path: item.Path, Size: item.Size, ModifiedAt: item.ModifiedAt})
|
||||
}
|
||||
var removed []protocol.VMOrphanItem
|
||||
if err := p.callWithTimeout(ctx, protocol.ActionCleanOrphans, payload, &removed, longRunningTimeout); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.VMOrphanItem, 0, len(removed))
|
||||
for _, item := range removed {
|
||||
out = append(out, dto.VMOrphanItem{Type: item.Type, Path: item.Path, Size: item.Size, ModifiedAt: item.ModifiedAt})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Get(ctx context.Context, name string, checkDisks bool) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.call(ctx, protocol.ActionGet, protocol.VMInfoReq{Name: name, CheckDisks: checkDisks}, &item); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) Stats(ctx context.Context, name string) (dto.VMStats, error) {
|
||||
var item protocol.VMStats
|
||||
if err := p.call(ctx, protocol.ActionStats, protocol.VMName{Name: name}, &item); err != nil {
|
||||
return dto.VMStats{}, err
|
||||
}
|
||||
return dto.VMStats{
|
||||
Name: item.Name, Status: item.Status, Timestamp: item.Timestamp, CPUTime: item.CPUTime, VCPU: item.VCPU,
|
||||
Memory: dto.VMMemoryStats(item.Memory), Disk: dto.VMDiskStats(item.Disk), Network: dto.VMNetworkStats(item.Network),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Create(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.call(ctx, protocol.ActionCreate, toProtocolConfig(cfg), &item); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateFromTemplate(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.callWithTimeout(ctx, protocol.ActionCreateFromTemplate, toProtocolConfig(cfg), &item, longRunningTimeout); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateTemplate(ctx context.Context, req types.VMTemplateCreate) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.callWithTimeout(ctx, protocol.ActionCreateTemplate, protocol.VMTemplateCreate{
|
||||
Name: req.Name, SourceName: req.SourceName, DiskPath: req.DiskPath, StoragePool: req.StoragePool,
|
||||
}, &item, longRunningTimeout); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) Update(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.call(ctx, protocol.ActionUpdate, toProtocolConfig(cfg), &item); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) ResizeDisk(ctx context.Context, req dto.VMDiskResize) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.callWithTimeout(ctx, protocol.ActionResizeDisk, protocol.VMDiskResize{Name: req.Name, Target: req.Target, SizeGiB: req.SizeGiB}, &item, longRunningTimeout); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) Rename(ctx context.Context, name string, newName string) (dto.VMInfo, error) {
|
||||
var item protocol.VMInfo
|
||||
if err := p.call(ctx, protocol.ActionRename, protocol.VMRename{Name: name, NewName: newName}, &item); err != nil {
|
||||
return dto.VMInfo{}, err
|
||||
}
|
||||
return toDTO(item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) Delete(ctx context.Context, name string, force bool, deleteDisk bool, protectedPaths []string) error {
|
||||
return p.call(ctx, protocol.ActionDelete, protocol.VMDelete{Name: name, Force: force, DeleteDisk: deleteDisk, ProtectedPaths: protectedPaths}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) ListSnapshots(ctx context.Context, name string) ([]dto.VMSnapshotInfo, error) {
|
||||
var items []protocol.VMSnapshotInfo
|
||||
if err := p.call(ctx, protocol.ActionListSnapshots, protocol.VMName{Name: name}, &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]dto.VMSnapshotInfo, 0, len(items))
|
||||
for _, item := range items {
|
||||
out = append(out, dto.VMSnapshotInfo(item))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (p *Provider) CreateSnapshot(ctx context.Context, name string, snapshotName string, description string) error {
|
||||
return p.call(ctx, protocol.ActionCreateSnapshot, protocol.VMSnapshotReq{
|
||||
Name: name, SnapshotName: snapshotName, Description: description,
|
||||
}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) RecoverSnapshot(ctx context.Context, name string, snapshotName string) error {
|
||||
return p.call(ctx, protocol.ActionRecoverSnapshot, protocol.VMSnapshotReq{Name: name, SnapshotName: snapshotName}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) DeleteSnapshot(ctx context.Context, name string, snapshotName string) error {
|
||||
return p.call(ctx, protocol.ActionDeleteSnapshot, protocol.VMSnapshotReq{Name: name, SnapshotName: snapshotName}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) Start(ctx context.Context, name string) error {
|
||||
return p.lifecycle(ctx, protocol.ActionStart, name)
|
||||
}
|
||||
|
||||
func (p *Provider) Shutdown(ctx context.Context, name string) error {
|
||||
return p.lifecycle(ctx, protocol.ActionShutdown, name)
|
||||
}
|
||||
|
||||
func (p *Provider) Reboot(ctx context.Context, name string) error {
|
||||
return p.lifecycle(ctx, protocol.ActionReboot, name)
|
||||
}
|
||||
|
||||
func (p *Provider) Destroy(ctx context.Context, name string) error {
|
||||
return p.lifecycle(ctx, protocol.ActionDestroy, name)
|
||||
}
|
||||
|
||||
func (p *Provider) Suspend(ctx context.Context, name string) error {
|
||||
return p.lifecycle(ctx, protocol.ActionSuspend, name)
|
||||
}
|
||||
|
||||
func (p *Provider) Resume(ctx context.Context, name string) error {
|
||||
return p.lifecycle(ctx, protocol.ActionResume, name)
|
||||
}
|
||||
|
||||
func (p *Provider) Console(ctx context.Context, name string) (*dto.VMGraphics, error) {
|
||||
var item protocol.VMGraphics
|
||||
if err := p.call(ctx, protocol.ActionConsole, protocol.VMName{Name: name}, &item); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return (*dto.VMGraphics)(&item), nil
|
||||
}
|
||||
|
||||
func (p *Provider) lifecycle(ctx context.Context, action string, name string) error {
|
||||
return p.call(ctx, action, protocol.VMName{Name: name}, nil)
|
||||
}
|
||||
|
||||
func (p *Provider) checkVersion(ctx context.Context) error {
|
||||
var info protocol.VersionInfo
|
||||
if err := p.call(ctx, protocol.ActionVersion, nil, &info); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.API != protocol.MinVersion {
|
||||
return buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("unsupported kvm helper api version: %s", info.API))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) call(ctx context.Context, action string, payload interface{}, out interface{}) error {
|
||||
return p.callWithTimeout(ctx, action, payload, out, p.Timeout)
|
||||
}
|
||||
|
||||
func (p *Provider) callWithTimeout(ctx context.Context, action string, payload interface{}, out interface{}, timeout time.Duration) (retErr error) {
|
||||
startedAt := time.Now()
|
||||
defer func() {
|
||||
logAction(action, time.Since(startedAt), retErr)
|
||||
}()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = defaultTimeout
|
||||
}
|
||||
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
if action != protocol.ActionVersion {
|
||||
if err := p.ensureVersion(runCtx); err != nil {
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
return buserr.New("ErrCmdTimeout")
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
req := protocol.Request{Version: protocol.Version, Action: action, URI: p.URI, Payload: payload}
|
||||
input, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
helperPath, err := p.resolveHelperPath()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd := exec.Command(helperPath)
|
||||
cmd.Stdin = bytes.NewReader(input)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err = runHelperCommand(runCtx, cmd)
|
||||
if stderr.Len() > 0 && global.LOG != nil {
|
||||
global.LOG.Warnf("kvm helper stderr: %s", stderr.String())
|
||||
}
|
||||
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
|
||||
return buserr.New("ErrCmdTimeout")
|
||||
}
|
||||
if err != nil {
|
||||
return buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("helper failed: %w, stderr: %s", err, stderr.String()))
|
||||
}
|
||||
var resp protocol.Response
|
||||
if err := json.Unmarshal(stdout.Bytes(), &resp); err != nil {
|
||||
return buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("invalid helper response: %w", err))
|
||||
}
|
||||
if !resp.Success {
|
||||
return mapHelperError(resp.Error)
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
data, err := json.Marshal(resp.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func logAction(action string, duration time.Duration, err error) {
|
||||
if global.LOG == nil {
|
||||
return
|
||||
}
|
||||
durationMs := duration.Milliseconds()
|
||||
if err != nil {
|
||||
global.LOG.Errorf("[kvm] action failed, action=%s, duration_ms=%d, err=%v", action, durationMs, err)
|
||||
return
|
||||
}
|
||||
if duration >= slowActionThreshold {
|
||||
global.LOG.Warnf("[kvm] slow action, action=%s, duration_ms=%d", action, durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
func runHelperCommand(ctx context.Context, cmd *exec.Cmd) error {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Signal(os.Interrupt)
|
||||
}
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case err := <-done:
|
||||
return err
|
||||
case <-timer.C:
|
||||
if cmd.Process != nil {
|
||||
_ = cmd.Process.Kill()
|
||||
}
|
||||
return <-done
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Provider) ensureVersion(ctx context.Context) error {
|
||||
p.versionOnce.Do(func() {
|
||||
p.versionGate = make(chan struct{}, 1)
|
||||
})
|
||||
select {
|
||||
case p.versionGate <- struct{}{}:
|
||||
defer func() { <-p.versionGate }()
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.checked {
|
||||
return nil
|
||||
}
|
||||
if err := p.checkVersion(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
p.checked = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Provider) resolveHelperPath() (string, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.HelperPath != "" {
|
||||
return p.HelperPath, nil
|
||||
}
|
||||
path, err := findHelperPath()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.HelperPath = path
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func mapHelperError(item *protocol.Error) error {
|
||||
if item == nil {
|
||||
return buserr.New("ErrVMCommandFailed")
|
||||
}
|
||||
switch item.Code {
|
||||
case "MAIN_DISK_DELETE_FAILED":
|
||||
return item
|
||||
case "VM_NOT_FOUND":
|
||||
return buserr.New("ErrVMNotFound")
|
||||
case "VM_ALREADY_EXISTS":
|
||||
return buserr.New("ErrVMAlreadyExists")
|
||||
case "VM_IMAGE_ALREADY_EXISTS":
|
||||
return buserr.WithDetail("ErrVMImageAlreadyExists", item.Detail, errors.New(item.Detail))
|
||||
case "DISK_RESIZE_BLOCKED":
|
||||
if item.Detail == "referenceCheck" {
|
||||
return buserr.New("ErrVMDiskReferenceCheck")
|
||||
}
|
||||
if item.Detail == "referenced" {
|
||||
return buserr.New("ErrVMDiskReferenced")
|
||||
}
|
||||
return buserr.New("ErrVMDiskResizeBlocked")
|
||||
case "VM_RUNNING":
|
||||
return buserr.New("ErrVMRunning")
|
||||
case "LIBVIRT_CONNECT":
|
||||
return buserr.WithDetail("ErrVMLibvirtConnect", item.Detail, errors.New(item.Detail))
|
||||
case "PERMISSION_DENIED":
|
||||
return buserr.WithDetail("ErrVMPermission", item.Detail, errors.New(item.Detail))
|
||||
case "NETWORK_NOT_FOUND":
|
||||
return buserr.WithDetail("ErrVMNetworkNotFound", item.Detail, errors.New(item.Detail))
|
||||
case "INVALID_PATH":
|
||||
return buserr.WithDetail("ErrVMInvalidPath", item.Detail, errors.New(item.Detail))
|
||||
case "INVALID_INPUT":
|
||||
return buserr.WithDetail("ErrVMInvalidInput", item.Detail, errors.New(item.Detail))
|
||||
default:
|
||||
detail := item.Detail
|
||||
if detail == "" {
|
||||
detail = item.Message
|
||||
}
|
||||
return buserr.WithErr("ErrVMCommandFailed", errors.New(detail))
|
||||
}
|
||||
}
|
||||
|
||||
func findHelperPath() (string, error) {
|
||||
if path := os.Getenv("ONEPANEL_KVM"); path != "" {
|
||||
return path, nil
|
||||
}
|
||||
self, err := os.Executable()
|
||||
if err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(self), protocol.HelperBinary)
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
if path, err := exec.LookPath(protocol.HelperBinary); err == nil {
|
||||
return path, nil
|
||||
}
|
||||
return "", buserr.WithErr("ErrVMCommandFailed", fmt.Errorf("%s not found", protocol.HelperBinary))
|
||||
}
|
||||
|
||||
func toProtocolConfig(cfg types.VMConfig) protocol.VMConfig {
|
||||
return protocol.VMConfig{
|
||||
Name: cfg.Name, CPU: cfg.CPU, MemoryMiB: cfg.MemoryMiB, DiskSizeGiB: cfg.DiskSizeGiB,
|
||||
DiskBus: cfg.DiskBus, DiskPath: cfg.DiskPath, StoragePool: cfg.StoragePool, ISOPaths: cfg.ISOPaths, ImagePath: cfg.ImagePath,
|
||||
Network: cfg.Network, BootOrder: cfg.BootOrder, AutoStart: cfg.AutoStart,
|
||||
StartAfterCreate: cfg.StartAfterCreate,
|
||||
}
|
||||
}
|
||||
|
||||
func toDTO(item protocol.VMInfo) dto.VMInfo {
|
||||
out := dto.VMInfo{
|
||||
Name: item.Name, UUID: item.UUID, CPU: item.CPU, Memory: item.Memory, DiskSize: item.DiskSize,
|
||||
DiskBus: item.DiskBus, DiskPath: item.DiskPath, StoragePool: item.StoragePool, ISOPaths: item.ISOPaths, ImagePath: item.ImagePath,
|
||||
Network: item.Network, BootOrder: item.BootOrder, AutoStart: item.AutoStart, Status: item.Status,
|
||||
}
|
||||
for _, disk := range item.Disks {
|
||||
out.Disks = append(out.Disks, dto.VMDisk(disk))
|
||||
}
|
||||
for _, net := range item.Interfaces {
|
||||
out.Interfaces = append(out.Interfaces, dto.VMInterface(net))
|
||||
}
|
||||
if item.Graphics != nil {
|
||||
graphics := dto.VMGraphics(*item.Graphics)
|
||||
out.Graphics = &graphics
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package protocol
|
||||
|
||||
// VM helper protocol definition shared by the core caller and helper binary.
|
||||
|
||||
const (
|
||||
Version = "1.2.0"
|
||||
API = "1.2.0"
|
||||
MinVersion = "1.2.0"
|
||||
DefaultURI = "qemu:///system"
|
||||
HelperBinary = "1panel-kvm"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionVersion = "version"
|
||||
ActionCheck = "check"
|
||||
ActionHealthCheck = "health-check"
|
||||
ActionList = "list"
|
||||
ActionGet = "get"
|
||||
ActionCreate = "create"
|
||||
ActionUpdate = "update"
|
||||
ActionResizeDisk = "resizeDisk"
|
||||
ActionRename = "rename"
|
||||
ActionDelete = "delete"
|
||||
ActionStart = "start"
|
||||
ActionShutdown = "shutdown"
|
||||
ActionReboot = "reboot"
|
||||
ActionDestroy = "destroy"
|
||||
ActionSuspend = "suspend"
|
||||
ActionResume = "resume"
|
||||
ActionConsole = "console"
|
||||
ActionStats = "stats"
|
||||
ActionListSnapshots = "listSnapshots"
|
||||
ActionCreateSnapshot = "createSnapshot"
|
||||
ActionRecoverSnapshot = "recoverSnapshot"
|
||||
ActionDeleteSnapshot = "deleteSnapshot"
|
||||
ActionCreateTemplate = "createTemplate"
|
||||
ActionCreateFromTemplate = "createFromTemplate"
|
||||
ActionResources = "resources"
|
||||
ActionCreateResource = "createResource"
|
||||
ActionUpdateResource = "updateResource"
|
||||
ActionDeleteResource = "deleteResource"
|
||||
ActionScanOrphans = "scanOrphans"
|
||||
ActionCleanOrphans = "cleanOrphans"
|
||||
)
|
||||
|
||||
type Request struct {
|
||||
Version string `json:"version"`
|
||||
Action string `json:"action"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
Payload any `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Success bool `json:"success"`
|
||||
Data any `json:"data,omitempty"`
|
||||
Error *Error `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
if e.Detail != "" {
|
||||
return e.Message + ": " + e.Detail
|
||||
}
|
||||
return e.Message
|
||||
}
|
||||
|
||||
type VersionInfo struct {
|
||||
Version string `json:"version"`
|
||||
API string `json:"api"`
|
||||
}
|
||||
|
||||
type VMConfig struct {
|
||||
Name string `json:"name"`
|
||||
CPU int `json:"cpu"`
|
||||
MemoryMiB int64 `json:"memoryMiB"`
|
||||
DiskSizeGiB int64 `json:"diskSizeGiB"`
|
||||
DiskBus string `json:"diskBus,omitempty"`
|
||||
DiskPath string `json:"diskPath,omitempty"`
|
||||
StoragePool string `json:"storagePool,omitempty"`
|
||||
ISOPaths []string `json:"isoPaths"`
|
||||
ImagePath string `json:"imagePath,omitempty"`
|
||||
Network string `json:"network"`
|
||||
BootOrder string `json:"bootOrder,omitempty"`
|
||||
StartAfterCreate bool `json:"startAfterCreate"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
}
|
||||
|
||||
type VMInfoReq struct {
|
||||
Name string `json:"name"`
|
||||
CheckDisks bool `json:"checkDisks,omitempty"`
|
||||
}
|
||||
|
||||
type VMName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type VMDiskResize struct {
|
||||
Name string `json:"name"`
|
||||
Target string `json:"target"`
|
||||
SizeGiB int64 `json:"sizeGiB"`
|
||||
}
|
||||
|
||||
type VMRename struct {
|
||||
Name string `json:"name"`
|
||||
NewName string `json:"newName"`
|
||||
}
|
||||
|
||||
type VMDelete struct {
|
||||
Name string `json:"name"`
|
||||
Force bool `json:"force"`
|
||||
DeleteDisk bool `json:"deleteDisk"`
|
||||
ProtectedPaths []string `json:"protectedPaths,omitempty"`
|
||||
}
|
||||
|
||||
type VMSnapshotReq struct {
|
||||
Name string `json:"name"`
|
||||
SnapshotName string `json:"snapshotName,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type VMSnapshotInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
CreatedAt int64 `json:"createdAt"`
|
||||
State string `json:"state"`
|
||||
Parent string `json:"parent"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
Current bool `json:"current"`
|
||||
Children int `json:"children"`
|
||||
}
|
||||
|
||||
type VMTemplateCreate struct {
|
||||
Name string `json:"name"`
|
||||
SourceName string `json:"sourceName"`
|
||||
DiskPath string `json:"diskPath,omitempty"`
|
||||
StoragePool string `json:"storagePool,omitempty"`
|
||||
}
|
||||
|
||||
type VMOrphanScan struct {
|
||||
ProtectedPaths []string `json:"protectedPaths,omitempty"`
|
||||
}
|
||||
|
||||
type VMOrphanItem struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
Size int64 `json:"size"`
|
||||
ModifiedAt int64 `json:"modifiedAt"`
|
||||
}
|
||||
|
||||
type VMOrphanClean struct {
|
||||
Items []VMOrphanItem `json:"items"`
|
||||
ProtectedPaths []string `json:"protectedPaths,omitempty"`
|
||||
}
|
||||
|
||||
type VMCheckItem struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Message string `json:"message"`
|
||||
IsEnable bool `json:"isEnable"`
|
||||
IsExist bool `json:"isExist"`
|
||||
IsActive bool `json:"isActive"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
|
||||
const (
|
||||
HealthCheckLevelPass = "PASS"
|
||||
HealthCheckLevelInfo = "INFO"
|
||||
HealthCheckLevelWarn = "WARN"
|
||||
HealthCheckLevelFail = "FAIL"
|
||||
)
|
||||
|
||||
type VMHealthCheckRequest struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type VMHealthCheckItem struct {
|
||||
Level string `json:"level"`
|
||||
Check string `json:"check"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Params map[string]string `json:"params,omitempty"`
|
||||
Detail string `json:"detail,omitempty"`
|
||||
FixCommand string `json:"fixCommand,omitempty"`
|
||||
}
|
||||
|
||||
type VMHealthCheckResult struct {
|
||||
Pass int `json:"pass"`
|
||||
Warn int `json:"warn"`
|
||||
Fail int `json:"fail"`
|
||||
Items []VMHealthCheckItem `json:"items"`
|
||||
}
|
||||
|
||||
type VMResourceSearch struct {
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
type VMResourceInfo struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
SubType string `json:"subType"`
|
||||
BridgeName string `json:"bridgeName,omitempty"`
|
||||
SourceInterface string `json:"sourceInterface,omitempty"`
|
||||
CIDR string `json:"cidr,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DHCPStart string `json:"dhcpStart,omitempty"`
|
||||
DHCPEnd string `json:"dhcpEnd,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Status string `json:"status"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Capacity uint64 `json:"capacity,omitempty"`
|
||||
Available uint64 `json:"available,omitempty"`
|
||||
}
|
||||
|
||||
type VMResourceCreate struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
SubType string `json:"subType"`
|
||||
BridgeName string `json:"bridgeName,omitempty"`
|
||||
SourceInterface string `json:"sourceInterface,omitempty"`
|
||||
CIDR string `json:"cidr,omitempty"`
|
||||
Gateway string `json:"gateway,omitempty"`
|
||||
DHCPStart string `json:"dhcpStart,omitempty"`
|
||||
DHCPEnd string `json:"dhcpEnd,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
}
|
||||
|
||||
type VMResourceUpdate struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
AutoStart *bool `json:"autoStart,omitempty"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type VMResourceDelete struct {
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type VMInfo struct {
|
||||
Name string `json:"name"`
|
||||
UUID string `json:"uuid"`
|
||||
CPU int `json:"cpu"`
|
||||
Memory int64 `json:"memory"`
|
||||
DiskSize int64 `json:"diskSize"`
|
||||
DiskBus string `json:"diskBus"`
|
||||
DiskPath string `json:"diskPath"`
|
||||
StoragePool string `json:"storagePool"`
|
||||
ISOPaths []string `json:"isoPaths"`
|
||||
ImagePath string `json:"imagePath"`
|
||||
Network string `json:"network"`
|
||||
BootOrder string `json:"bootOrder"`
|
||||
AutoStart bool `json:"autoStart"`
|
||||
Status string `json:"status"`
|
||||
Disks []VMDisk `json:"disks"`
|
||||
Interfaces []VMInterface `json:"interfaces"`
|
||||
Graphics *VMGraphics `json:"graphics"`
|
||||
}
|
||||
|
||||
type VMDisk struct {
|
||||
Capacity int64 `json:"capacity"`
|
||||
Format string `json:"format"`
|
||||
ReadOnly bool `json:"readOnly"`
|
||||
Shareable bool `json:"shareable"`
|
||||
ResizeBlocked string `json:"resizeBlocked"`
|
||||
Device string `json:"device"`
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Bus string `json:"bus"`
|
||||
}
|
||||
|
||||
type VMInterface struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Model string `json:"model"`
|
||||
MAC string `json:"mac"`
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type VMGraphics struct {
|
||||
Type string `json:"type"`
|
||||
Port string `json:"port"`
|
||||
Listen string `json:"listen"`
|
||||
}
|
||||
|
||||
type VMStats struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
CPUTime uint64 `json:"cpuTime"`
|
||||
VCPU int `json:"vcpu"`
|
||||
Memory VMMemoryStats `json:"memory"`
|
||||
Disk VMDiskStats `json:"disk"`
|
||||
Network VMNetworkStats `json:"network"`
|
||||
}
|
||||
|
||||
type VMMemoryStats struct {
|
||||
AvailableKiB uint64 `json:"availableKiB"`
|
||||
UnusedKiB uint64 `json:"unusedKiB"`
|
||||
UsableKiB uint64 `json:"usableKiB"`
|
||||
RSSKiB uint64 `json:"rssKiB"`
|
||||
ActualKiB uint64 `json:"actualKiB"`
|
||||
}
|
||||
|
||||
type VMDiskStats struct {
|
||||
ReadBytes uint64 `json:"readBytes"`
|
||||
WriteBytes uint64 `json:"writeBytes"`
|
||||
ReadReqs uint64 `json:"readReqs"`
|
||||
WriteReqs uint64 `json:"writeReqs"`
|
||||
}
|
||||
|
||||
type VMNetworkStats struct {
|
||||
RxBytes uint64 `json:"rxBytes"`
|
||||
TxBytes uint64 `json:"txBytes"`
|
||||
RxPackets uint64 `json:"rxPackets"`
|
||||
TxPackets uint64 `json:"txPackets"`
|
||||
}
|
||||
|
||||
func OK(data any) Response {
|
||||
return Response{Success: true, Data: data}
|
||||
}
|
||||
|
||||
func Fail(code string, message string, detail string) Response {
|
||||
return Response{Success: false, Error: &Error{Code: code, Message: message, Detail: detail}}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package types
|
||||
|
||||
type VMConfig struct {
|
||||
Name string
|
||||
CPU int
|
||||
MemoryMiB int64
|
||||
DiskSizeGiB int64
|
||||
DiskBus string
|
||||
DiskPath string
|
||||
StoragePool string
|
||||
ISOPaths []string
|
||||
ImagePath string
|
||||
Network string
|
||||
BootOrder string
|
||||
StartAfterCreate bool
|
||||
AutoStart bool
|
||||
}
|
||||
|
||||
type VMTemplateCreate struct {
|
||||
Name string
|
||||
SourceName string
|
||||
DiskPath string
|
||||
StoragePool string
|
||||
}
|
||||
|
||||
type VMResourceCreate struct {
|
||||
Type string
|
||||
Name string
|
||||
SubType string
|
||||
BridgeName string
|
||||
SourceInterface string
|
||||
CIDR string
|
||||
Gateway string
|
||||
DHCPStart string
|
||||
DHCPEnd string
|
||||
Path string
|
||||
AutoStart bool
|
||||
}
|
||||
|
||||
type VMResourceUpdate struct {
|
||||
Type string
|
||||
Name string
|
||||
AutoStart *bool
|
||||
Status *string
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package vm
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/1Panel-dev/1Panel/agent/app/dto"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/helper"
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/vm/types"
|
||||
)
|
||||
|
||||
type VMProvider interface {
|
||||
CheckEnvironment(ctx context.Context) ([]dto.VMCheckItem, error)
|
||||
HealthCheck(ctx context.Context, name string) (dto.VMHealthCheckResult, error)
|
||||
HelperStatus() bool
|
||||
List(ctx context.Context) ([]dto.VMInfo, error)
|
||||
ListResources(ctx context.Context, resourceType string) ([]dto.VMResourceInfo, error)
|
||||
CreateResource(ctx context.Context, req types.VMResourceCreate) error
|
||||
UpdateResource(ctx context.Context, req types.VMResourceUpdate) error
|
||||
DeleteResource(ctx context.Context, resourceType string, name string) error
|
||||
ScanOrphans(ctx context.Context, protectedPaths []string) ([]dto.VMOrphanItem, error)
|
||||
CleanOrphans(ctx context.Context, items []dto.VMOrphanItem, protectedPaths []string) ([]dto.VMOrphanItem, error)
|
||||
Get(ctx context.Context, name string, checkDisks bool) (dto.VMInfo, error)
|
||||
Stats(ctx context.Context, name string) (dto.VMStats, error)
|
||||
Create(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error)
|
||||
CreateFromTemplate(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error)
|
||||
CreateTemplate(ctx context.Context, req types.VMTemplateCreate) (dto.VMInfo, error)
|
||||
Update(ctx context.Context, cfg types.VMConfig) (dto.VMInfo, error)
|
||||
ResizeDisk(ctx context.Context, req dto.VMDiskResize) (dto.VMInfo, error)
|
||||
Rename(ctx context.Context, name string, newName string) (dto.VMInfo, error)
|
||||
Delete(ctx context.Context, name string, force bool, deleteDisk bool, protectedPaths []string) error
|
||||
ListSnapshots(ctx context.Context, name string) ([]dto.VMSnapshotInfo, error)
|
||||
CreateSnapshot(ctx context.Context, name string, snapshotName string, description string) error
|
||||
RecoverSnapshot(ctx context.Context, name string, snapshotName string) error
|
||||
DeleteSnapshot(ctx context.Context, name string, snapshotName string) error
|
||||
Start(ctx context.Context, name string) error
|
||||
Shutdown(ctx context.Context, name string) error
|
||||
Reboot(ctx context.Context, name string) error
|
||||
Destroy(ctx context.Context, name string) error
|
||||
Suspend(ctx context.Context, name string) error
|
||||
Resume(ctx context.Context, name string) error
|
||||
Console(ctx context.Context, name string) (*dto.VMGraphics, error)
|
||||
}
|
||||
|
||||
func NewVMProvider() (VMProvider, error) {
|
||||
return helper.NewProvider()
|
||||
}
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
package xpack
|
||||
|
||||
import "github.com/1Panel-dev/1Panel/agent/utils/xpack/helper"
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/agent/utils/xpack/helper"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var AlertProvider = helper.NewIAlertProvider()
|
||||
|
||||
var MultiNodeProvider = helper.NewIMultiNodeProvider()
|
||||
|
||||
func VMCountLimit(_ *gin.Context) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -5220,7 +5220,7 @@
|
||||
"formatZH": "更新 vLLM [name]",
|
||||
"formatEN": "update vLLM [name]"
|
||||
},
|
||||
"/xpack/vms": {
|
||||
"/vms": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
@@ -5229,7 +5229,7 @@
|
||||
"formatZH": "创建虚拟机 [name]",
|
||||
"formatEN": "create VM [name]"
|
||||
},
|
||||
"/xpack/vms/del": {
|
||||
"/vms/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5247,7 +5247,7 @@
|
||||
"formatZH": "删除虚拟机 [name]",
|
||||
"formatEN": "delete VM [name]"
|
||||
},
|
||||
"/xpack/vms/disks/resize": {
|
||||
"/vms/disks/resize": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"target",
|
||||
@@ -5258,14 +5258,14 @@
|
||||
"formatZH": "扩容虚拟机 [name] 磁盘 [target] 至 [sizeGiB] GiB",
|
||||
"formatEN": "expand VM [name] disk [target] to [sizeGiB] GiB"
|
||||
},
|
||||
"/xpack/vms/environment/enable": {
|
||||
"/vms/environment/enable": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "启用虚拟机运行环境",
|
||||
"formatEN": "enable VM runtime environment"
|
||||
},
|
||||
"/xpack/vms/iso": {
|
||||
"/vms/iso": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5275,7 +5275,7 @@
|
||||
"formatZH": "创建虚拟机镜像 [name] [type]",
|
||||
"formatEN": "create VM ISO [name] [type]"
|
||||
},
|
||||
"/xpack/vms/iso/del": {
|
||||
"/vms/iso/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5293,7 +5293,7 @@
|
||||
"formatZH": "删除虚拟机镜像 [name]",
|
||||
"formatEN": "delete VM ISO [name]"
|
||||
},
|
||||
"/xpack/vms/iso/update": {
|
||||
"/vms/iso/update": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5303,7 +5303,7 @@
|
||||
"formatZH": "更新虚拟机镜像 [name] [type]",
|
||||
"formatEN": "update VM ISO [name] [type]"
|
||||
},
|
||||
"/xpack/vms/networks": {
|
||||
"/vms/networks": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5313,7 +5313,7 @@
|
||||
"formatZH": "创建虚拟机网络 [name] [type]",
|
||||
"formatEN": "create VM network [name] [type]"
|
||||
},
|
||||
"/xpack/vms/networks/del": {
|
||||
"/vms/networks/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5331,7 +5331,7 @@
|
||||
"formatZH": "删除虚拟机网络 [name]",
|
||||
"formatEN": "delete VM network [name]"
|
||||
},
|
||||
"/xpack/vms/networks/update": {
|
||||
"/vms/networks/update": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5349,7 +5349,7 @@
|
||||
"formatZH": "更新虚拟机网络 [name]",
|
||||
"formatEN": "update VM network [name]"
|
||||
},
|
||||
"/xpack/vms/operate": {
|
||||
"/vms/operate": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"operate"
|
||||
@@ -5359,14 +5359,14 @@
|
||||
"formatZH": "操作虚拟机 [name] [operate]",
|
||||
"formatEN": "operate VM [name] [operate]"
|
||||
},
|
||||
"/xpack/vms/orphans/clean": {
|
||||
"/vms/orphans/clean": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "清理虚拟机孤立数据",
|
||||
"formatEN": "clean VM orphaned data"
|
||||
},
|
||||
"/xpack/vms/rename": {
|
||||
"/vms/rename": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"newName"
|
||||
@@ -5376,7 +5376,7 @@
|
||||
"formatZH": "重命名虚拟机 [name] 为 [newName]",
|
||||
"formatEN": "rename VM [name] to [newName]"
|
||||
},
|
||||
"/xpack/vms/snapshots": {
|
||||
"/vms/snapshots": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"snapshotName"
|
||||
@@ -5386,7 +5386,7 @@
|
||||
"formatZH": "创建虚拟机 [name] 快照 [snapshotName]",
|
||||
"formatEN": "create VM [name] snapshot [snapshotName]"
|
||||
},
|
||||
"/xpack/vms/snapshots/del": {
|
||||
"/vms/snapshots/del": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"snapshotName"
|
||||
@@ -5396,7 +5396,7 @@
|
||||
"formatZH": "删除虚拟机 [name] 快照 [snapshotName]",
|
||||
"formatEN": "delete VM [name] snapshot [snapshotName]"
|
||||
},
|
||||
"/xpack/vms/snapshots/recover": {
|
||||
"/vms/snapshots/recover": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"snapshotName"
|
||||
@@ -5406,7 +5406,7 @@
|
||||
"formatZH": "恢复虚拟机 [name] 快照 [snapshotName]",
|
||||
"formatEN": "recover VM [name] snapshot [snapshotName]"
|
||||
},
|
||||
"/xpack/vms/storages": {
|
||||
"/vms/storages": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"type"
|
||||
@@ -5416,7 +5416,7 @@
|
||||
"formatZH": "创建虚拟机存储 [name] [type]",
|
||||
"formatEN": "create VM storage [name] [type]"
|
||||
},
|
||||
"/xpack/vms/storages/del": {
|
||||
"/vms/storages/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5434,7 +5434,7 @@
|
||||
"formatZH": "删除虚拟机存储 [name]",
|
||||
"formatEN": "delete VM storage [name]"
|
||||
},
|
||||
"/xpack/vms/storages/update": {
|
||||
"/vms/storages/update": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5452,14 +5452,14 @@
|
||||
"formatZH": "更新虚拟机存储 [name]",
|
||||
"formatEN": "update VM storage [name]"
|
||||
},
|
||||
"/xpack/vms/sync": {
|
||||
"/vms/sync": {
|
||||
"bodyKeys": [],
|
||||
"paramKeys": [],
|
||||
"beforeFunctions": [],
|
||||
"formatZH": "同步虚拟机",
|
||||
"formatEN": "sync virtual machines"
|
||||
},
|
||||
"/xpack/vms/templates": {
|
||||
"/vms/templates": {
|
||||
"bodyKeys": [
|
||||
"name",
|
||||
"templateName"
|
||||
@@ -5469,7 +5469,7 @@
|
||||
"formatZH": "创建虚拟机 [name] 模板 [templateName]",
|
||||
"formatEN": "create VM [name] template [templateName]"
|
||||
},
|
||||
"/xpack/vms/templates/del": {
|
||||
"/vms/templates/del": {
|
||||
"bodyKeys": [
|
||||
"id"
|
||||
],
|
||||
@@ -5487,7 +5487,7 @@
|
||||
"formatZH": "删除虚拟机模板 [name]",
|
||||
"formatEN": "delete VM template [name]"
|
||||
},
|
||||
"/xpack/vms/update": {
|
||||
"/vms/update": {
|
||||
"bodyKeys": [
|
||||
"name"
|
||||
],
|
||||
|
||||
@@ -233,13 +233,13 @@ var WebUrlMap = map[string]struct{}{
|
||||
"/enterprise/ops-report/alert": {},
|
||||
"/enterprise/ops-report/history": {},
|
||||
"/enterprise/ops-report/settings": {},
|
||||
"/xpack/vm": {},
|
||||
"/xpack/vm/overview": {},
|
||||
"/xpack/vm/list": {},
|
||||
"/xpack/vm/isos": {},
|
||||
"/xpack/vm/templates": {},
|
||||
"/xpack/vm/networks": {},
|
||||
"/xpack/vm/storage-pools": {},
|
||||
"/vm": {},
|
||||
"/vm/overview": {},
|
||||
"/vm/list": {},
|
||||
"/vm/isos": {},
|
||||
"/vm/templates": {},
|
||||
"/vm/networks": {},
|
||||
"/vm/storage-pools": {},
|
||||
}
|
||||
|
||||
var DynamicRoutes = []string{
|
||||
|
||||
@@ -120,6 +120,7 @@ func LoadMenus() string {
|
||||
{ID: "75", Disabled: false, Title: "menu.processManage", IsShow: true, Label: "Process", Path: "/hosts/process/process", Sort: 400},
|
||||
{ID: "76", Disabled: false, Title: "menu.ssh", IsShow: true, Label: "SSH", Path: "/hosts/ssh/ssh", Sort: 500},
|
||||
{ID: "77", Disabled: false, Title: "menu.disk", IsShow: true, Label: "Disk", Path: "/hosts/disk", Sort: 600},
|
||||
{ID: "123", Disabled: false, Title: "xpack.vm.title", IsShow: true, Label: "VirtualMachine", Path: "/vm", Sort: 900},
|
||||
}},
|
||||
{ID: "8", Disabled: false, Title: "menu.terminal", IsShow: true, Label: "Terminal-Menu", Path: "/hosts/terminal", Sort: 800},
|
||||
{ID: "10", Disabled: false, Title: "menu.cronjob", IsShow: true, Label: "Cronjob-Menu", Path: "/cronjobs", Sort: 900},
|
||||
@@ -131,7 +132,6 @@ func LoadMenus() string {
|
||||
{ID: "111", Disabled: false, Title: "xpack.node.nodeManagement", IsShow: true, Label: "NodeDashboard", Path: "/xpack/node/dashboard", Sort: 300},
|
||||
{ID: "113", Disabled: false, Title: "xpack.monitor.name", IsShow: true, Label: "MonitorDashboard", Path: "/xpack/monitor/dashboard", Sort: 600},
|
||||
{ID: "115", Disabled: false, Title: "xpack.sync.menu", IsShow: true, Label: "Sync", Path: "/xpack/sync", Sort: 700},
|
||||
{ID: "123", Disabled: false, Title: "xpack.vm.title", IsShow: true, Label: "VirtualMachine", Path: "/xpack/vm", Sort: 900},
|
||||
{ID: "114", Disabled: false, Title: "xpack.tamper.tamper", IsShow: true, Label: "Tamper", Path: "/xpack/tamper", Sort: 1000},
|
||||
{ID: "120", Disabled: false, Title: "xpack.cluster.cluster", IsShow: true, Label: "Cluster", Path: "/xpack/cluster", Sort: 1100},
|
||||
{ID: "117", Disabled: false, Title: "xpack.setting.setting", IsShow: true, Label: "XSetting", Path: "/xpack/setting", Sort: 1200},
|
||||
|
||||
@@ -73,5 +73,6 @@ func coreMigrations() []*gormigrate.Migration {
|
||||
migrations.UpdateAiMenuOrderV2,
|
||||
migrations.AddPanelMCP,
|
||||
migrations.AddAPIKeyDeletedAt,
|
||||
migrations.MoveVirtualMachineMenuToCommunity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,3 +32,45 @@ var MoveVirtualMachineMenuToXpack = &gormigrate.Migration{
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
var MoveVirtualMachineMenuToCommunity = &gormigrate.Migration{
|
||||
ID: "20261102-move-vm-menu-to-community",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
return helper.UpdateHideMenu(tx, func(menus []dto.ShowMenu) []dto.ShowMenu {
|
||||
systemIndex := -1
|
||||
for i := range menus {
|
||||
if menus[i].Label == "System-Menu" {
|
||||
systemIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if systemIndex < 0 {
|
||||
return menus
|
||||
}
|
||||
item := dto.ShowMenu{ID: "123", Title: "xpack.vm.title", IsShow: true, Label: "VirtualMachine", Path: "/vm", Sort: 900}
|
||||
for i := range menus {
|
||||
if menus[i].Label != "Xpack-Menu" {
|
||||
continue
|
||||
}
|
||||
children := menus[i].Children[:0]
|
||||
for _, child := range menus[i].Children {
|
||||
if child.Label == "VirtualMachine" {
|
||||
item = child
|
||||
item.Path = "/vm"
|
||||
continue
|
||||
}
|
||||
children = append(children, child)
|
||||
}
|
||||
menus[i].Children = children
|
||||
}
|
||||
for j := range menus[systemIndex].Children {
|
||||
if menus[systemIndex].Children[j].Label == "VirtualMachine" {
|
||||
menus[systemIndex].Children[j].Path = "/vm"
|
||||
return menus
|
||||
}
|
||||
}
|
||||
menus[systemIndex].Children = append(menus[systemIndex].Children, item)
|
||||
return menus
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ type agentProxyPolicy struct {
|
||||
func Proxy(policies ...agentProxyPolicy) gin.HandlerFunc {
|
||||
policies = append([]agentProxyPolicy(nil), policies...)
|
||||
return func(c *gin.Context) {
|
||||
c.Request.Header.Del("X-1Panel-VM-Limit")
|
||||
terminalsession.ClearForwardedHeaders(c)
|
||||
mcpapi.ClearForwardedHeaders(c.Request.Header)
|
||||
for _, policy := range policies {
|
||||
@@ -122,6 +123,10 @@ func Proxy(policies ...agentProxyPolicy) gin.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
if reqPath == "/api/v2/vms" || strings.HasPrefix(reqPath, "/api/v2/vms/") {
|
||||
c.Request.Header.Set("X-1Panel-VM-Limit", strconv.Itoa(xpack.VMCountLimit(c)))
|
||||
}
|
||||
|
||||
if reqPath == "/api/v2/hosts/terminal/local" && useLocalAgent {
|
||||
proxyLocalAgent(c)
|
||||
return
|
||||
|
||||
@@ -82,11 +82,11 @@ var demoAllowedRoutes = map[demoRoute]struct{}{
|
||||
}
|
||||
|
||||
var demoDeniedRoutes = map[demoRoute]struct{}{
|
||||
{http.MethodGet, "/api/v2/containers/exec"}: {},
|
||||
{http.MethodGet, "/api/v2/hosts/terminal/local"}: {},
|
||||
{http.MethodGet, "/api/v2/hosts/terminal/ssh"}: {},
|
||||
{http.MethodGet, "/api/v2/hosts/terminal/container"}: {},
|
||||
{http.MethodGet, "/api/v2/xpack/vms/console/ws"}: {},
|
||||
{http.MethodGet, "/api/v2/containers/exec"}: {},
|
||||
{http.MethodGet, "/api/v2/hosts/terminal/local"}: {},
|
||||
{http.MethodGet, "/api/v2/hosts/terminal/ssh"}: {},
|
||||
{http.MethodGet, "/api/v2/hosts/terminal/container"}: {},
|
||||
{http.MethodGet, "/api/v2/vms/console/ws"}: {},
|
||||
}
|
||||
|
||||
func DemoHandle() gin.HandlerFunc {
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
package xpack
|
||||
|
||||
import "github.com/1Panel-dev/1Panel/core/utils/xpack/helper"
|
||||
import (
|
||||
"github.com/1Panel-dev/1Panel/core/utils/xpack/helper"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
var AuthProvider = helper.NewIAuthProvider()
|
||||
|
||||
var MultiNodeProvider = helper.NewIMultiNodeProvider()
|
||||
|
||||
func VMCountLimit(_ *gin.Context) int {
|
||||
return 1
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
export namespace VM {
|
||||
export interface Search {
|
||||
info?: string;
|
||||
}
|
||||
|
||||
export interface TaskResp {
|
||||
taskID: string;
|
||||
}
|
||||
|
||||
export interface Environment {
|
||||
arch: string;
|
||||
archSupported: boolean;
|
||||
ready: boolean;
|
||||
helperExist: boolean;
|
||||
helperMessage: string;
|
||||
kvmExist: boolean;
|
||||
kvmMessage: string;
|
||||
libvirtExist: boolean;
|
||||
libvirtActive: boolean;
|
||||
libvirtMessage: string;
|
||||
storageExist: boolean;
|
||||
storageMessage: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckItem {
|
||||
level: 'PASS' | 'INFO' | 'WARN' | 'FAIL';
|
||||
check: string;
|
||||
reason?: string;
|
||||
params?: Record<string, string>;
|
||||
detail?: string;
|
||||
fixCommand?: string;
|
||||
}
|
||||
|
||||
export interface HealthCheckResult {
|
||||
pass: number;
|
||||
warn: number;
|
||||
fail: number;
|
||||
items: HealthCheckItem[];
|
||||
}
|
||||
|
||||
export interface Disk {
|
||||
capacity: number;
|
||||
format: string;
|
||||
readOnly: boolean;
|
||||
shareable: boolean;
|
||||
resizeBlocked: string;
|
||||
device: string;
|
||||
type: string;
|
||||
source: string;
|
||||
target: string;
|
||||
bus: string;
|
||||
}
|
||||
|
||||
export interface NetInterface {
|
||||
type: string;
|
||||
source: string;
|
||||
model: string;
|
||||
mac: string;
|
||||
ip: string;
|
||||
}
|
||||
|
||||
export interface Graphics {
|
||||
type: string;
|
||||
port: string;
|
||||
listen: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
export interface Stats {
|
||||
name: string;
|
||||
status: string;
|
||||
timestamp: number;
|
||||
cpuTime: number;
|
||||
vcpu: number;
|
||||
memory: MemoryStats;
|
||||
disk: DiskStats;
|
||||
network: NetworkStats;
|
||||
}
|
||||
|
||||
export interface MemoryStats {
|
||||
availableKiB: number;
|
||||
unusedKiB: number;
|
||||
usableKiB: number;
|
||||
rssKiB: number;
|
||||
actualKiB: number;
|
||||
}
|
||||
|
||||
export interface DiskStats {
|
||||
readBytes: number;
|
||||
writeBytes: number;
|
||||
readReqs: number;
|
||||
writeReqs: number;
|
||||
}
|
||||
|
||||
export interface NetworkStats {
|
||||
rxBytes: number;
|
||||
txBytes: number;
|
||||
rxPackets: number;
|
||||
txPackets: number;
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
uuid: string;
|
||||
cpu: number;
|
||||
/** Memory is measured in MiB. */
|
||||
memory: number;
|
||||
diskSize: number;
|
||||
diskBus: 'sata' | 'virtio' | string;
|
||||
diskPath: string;
|
||||
storagePool: string;
|
||||
isoPaths: string[];
|
||||
imagePath: string;
|
||||
network: string;
|
||||
bootOrder: string;
|
||||
autoStart: boolean;
|
||||
status: string;
|
||||
disks: Disk[];
|
||||
interfaces: NetInterface[];
|
||||
graphics?: Graphics;
|
||||
taskID: string;
|
||||
createTaskID: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface Create {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
cpu: number;
|
||||
/** Memory is measured in MiB. */
|
||||
memory: number;
|
||||
diskSize: number;
|
||||
osType?: 'windows' | 'linux' | 'other';
|
||||
diskBus?: 'sata' | 'virtio';
|
||||
templateID?: number;
|
||||
diskPath?: string;
|
||||
storagePool?: string;
|
||||
isoPaths: string[];
|
||||
imagePath?: string;
|
||||
network: string;
|
||||
bootOrder?: string;
|
||||
startAfterCreate: boolean;
|
||||
autoStart: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TemplateItem {
|
||||
id: number;
|
||||
name: string;
|
||||
sourceVM: string;
|
||||
cpu: number;
|
||||
/** Memory is measured in MiB. */
|
||||
memory: number;
|
||||
diskSize: number;
|
||||
diskBus: 'sata' | 'virtio' | string;
|
||||
diskPath: string;
|
||||
storagePool: string;
|
||||
network: string;
|
||||
bootOrder: string;
|
||||
isExist: boolean;
|
||||
size: number;
|
||||
status: 'uploading' | 'downloading' | 'ready' | 'failed' | string;
|
||||
error: string;
|
||||
tempFileID: string;
|
||||
tempFileType: 'upload' | 'download' | string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface TemplateCreate {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
templateName: string;
|
||||
storagePool?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface TemplateDelete {
|
||||
taskID?: string;
|
||||
id: number;
|
||||
deleteDisk: boolean;
|
||||
}
|
||||
|
||||
export interface Update {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
cpu: number;
|
||||
/** Memory is measured in MiB. */
|
||||
memory: number;
|
||||
isoPaths: string[];
|
||||
network: string;
|
||||
bootOrder?: string;
|
||||
autoStart: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DiskResize {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
target: string;
|
||||
sizeGiB: number;
|
||||
}
|
||||
|
||||
export interface Rename {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
export interface Delete {
|
||||
taskID?: string;
|
||||
id: number;
|
||||
force: boolean;
|
||||
}
|
||||
|
||||
export interface SnapshotItem {
|
||||
name: string;
|
||||
description: string;
|
||||
createdAt: number;
|
||||
state: string;
|
||||
parent: string;
|
||||
path: string;
|
||||
size: number;
|
||||
current: boolean;
|
||||
children: number;
|
||||
}
|
||||
|
||||
export interface SnapshotCreate {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
snapshotName: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SnapshotOperate {
|
||||
taskID?: string;
|
||||
name: string;
|
||||
snapshotName: string;
|
||||
}
|
||||
|
||||
export interface IsoItem {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
path: string;
|
||||
isExist: boolean;
|
||||
size: number;
|
||||
status: string;
|
||||
error: string;
|
||||
tempFileID: string;
|
||||
tempFileType: string;
|
||||
createdAt: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface IsoCreate {
|
||||
type: string;
|
||||
name: string;
|
||||
path: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IsoUpdate {
|
||||
id: number;
|
||||
type: string;
|
||||
name: string;
|
||||
path: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface IsoDelete {
|
||||
id: number;
|
||||
deleteFile?: boolean;
|
||||
}
|
||||
|
||||
export interface IsoImportStart extends IsoCreate {
|
||||
status: 'uploading' | 'downloading';
|
||||
tempFileID?: string;
|
||||
tempFileType?: 'upload' | 'download';
|
||||
}
|
||||
|
||||
export interface IsoImportUpdate {
|
||||
id: number;
|
||||
status: 'uploading' | 'downloading' | 'ready' | 'failed';
|
||||
error?: string;
|
||||
tempFileID?: string;
|
||||
tempFileType?: 'upload' | 'download';
|
||||
}
|
||||
|
||||
export interface ResourceDelete {
|
||||
id: number;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'nat' | 'bridge' | 'macvtap' | string;
|
||||
bridgeName: string;
|
||||
sourceInterface: string;
|
||||
cidr: string;
|
||||
gateway: string;
|
||||
dhcpStart: string;
|
||||
dhcpEnd: string;
|
||||
autoStart: boolean;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
vmCount: number;
|
||||
vms: ResourceVM[];
|
||||
registered: boolean;
|
||||
usedIPs: NetworkIPUsage[];
|
||||
}
|
||||
|
||||
export interface ResourceVM {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface NetworkIPUsage {
|
||||
ip: string;
|
||||
vmName: string;
|
||||
mac: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface NetworkInterfaceItem {
|
||||
name: string;
|
||||
addresses: string[];
|
||||
}
|
||||
|
||||
export interface NetworkBridgeItem {
|
||||
name: string;
|
||||
recommended: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface NetworkCreate {
|
||||
name: string;
|
||||
type: 'nat' | 'bridge' | 'macvtap';
|
||||
bridgeName?: string;
|
||||
sourceInterface?: string;
|
||||
cidr?: string;
|
||||
gateway?: string;
|
||||
dhcpStart?: string;
|
||||
dhcpEnd?: string;
|
||||
autoStart: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface NetworkUpdate {
|
||||
id: number;
|
||||
autoStart?: boolean;
|
||||
status?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface StorageItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: 'dir' | string;
|
||||
path: string;
|
||||
autoStart: boolean;
|
||||
description: string;
|
||||
createdAt: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
capacity: number;
|
||||
available: number;
|
||||
usedPercent?: number;
|
||||
vmCount: number;
|
||||
vms: ResourceVM[];
|
||||
}
|
||||
|
||||
export interface StorageCreate {
|
||||
name: string;
|
||||
type: 'dir';
|
||||
path: string;
|
||||
autoStart: boolean;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface StorageUpdate {
|
||||
id: number;
|
||||
name?: string;
|
||||
path?: string;
|
||||
autoStart?: boolean;
|
||||
status?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface OrphanItem {
|
||||
type: 'orphan_domain_disk' | 'orphan_snapshot' | 'partial_file';
|
||||
path: string;
|
||||
size: number;
|
||||
modifiedAt: number;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import http from '@/api';
|
||||
import { ResPage, SearchWithPage } from '@/api/interface';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { TimeoutEnum } from '@/enums/http-enum';
|
||||
|
||||
export const checkVMEnvironment = () => {
|
||||
return http.get<VM.Environment>('/vms/environment');
|
||||
};
|
||||
|
||||
export const enableVMEnvironment = () => {
|
||||
return http.post('/vms/environment/enable', {}, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const searchVMs = (params: SearchWithPage) => {
|
||||
return http.post<ResPage<VM.Item> & { limit: number }>('/vms/search', params);
|
||||
};
|
||||
|
||||
export const syncVMs = () => {
|
||||
return http.post('/vms/sync');
|
||||
};
|
||||
|
||||
export const getVM = (name: string, checkDisks = false) => {
|
||||
return http.post<VM.Item>('/vms/info', { name, checkDisks }, checkDisks ? TimeoutEnum.T_3M : undefined);
|
||||
};
|
||||
|
||||
export const healthCheckVM = (name: string, signal?: AbortSignal) => {
|
||||
return http.postWithConfig<VM.HealthCheckResult>(
|
||||
'/vms/health/check',
|
||||
{ name },
|
||||
{ timeout: TimeoutEnum.T_5M, signal },
|
||||
);
|
||||
};
|
||||
|
||||
export const getVMStats = (name: string) => {
|
||||
return http.post<VM.Stats>('/vms/stats', { name });
|
||||
};
|
||||
|
||||
export const createVM = (params: VM.Create) => {
|
||||
return http.post<VM.TaskResp>('/vms', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const listVMTemplates = () => {
|
||||
return http.post<VM.TemplateItem[]>('/vms/templates/list');
|
||||
};
|
||||
|
||||
export const createVMTemplate = (params: VM.TemplateCreate) => {
|
||||
return http.post<VM.TaskResp>('/vms/templates', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const deleteVMTemplate = (params: VM.TemplateDelete) => {
|
||||
return http.post<VM.TaskResp>('/vms/templates/del', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const updateVM = (params: VM.Update) => {
|
||||
return http.post<VM.TaskResp>('/vms/update', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const resizeVMDisk = (params: VM.DiskResize) => {
|
||||
return http.post<VM.TaskResp>('/vms/disks/resize', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const renameVM = (params: VM.Rename) => {
|
||||
return http.post<VM.TaskResp>('/vms/rename', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const deleteVM = (params: VM.Delete) => {
|
||||
return http.post<VM.TaskResp>('/vms/del', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const listVMSnapshots = (name: string) => {
|
||||
return http.post<VM.SnapshotItem[]>('/vms/snapshots/list', { name });
|
||||
};
|
||||
|
||||
export const createVMSnapshot = (params: VM.SnapshotCreate) => {
|
||||
return http.post<VM.TaskResp>('/vms/snapshots', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const recoverVMSnapshot = (params: VM.SnapshotOperate) => {
|
||||
return http.post<VM.TaskResp>('/vms/snapshots/recover', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const deleteVMSnapshot = (params: VM.SnapshotOperate) => {
|
||||
return http.post<VM.TaskResp>('/vms/snapshots/del', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const operateVM = (name: string, action: string) => {
|
||||
return http.post(`/vms/operate`, { name: name, operate: action });
|
||||
};
|
||||
|
||||
export const getVMConsole = (name: string) => {
|
||||
return http.post<VM.Graphics>('/vms/console', { name });
|
||||
};
|
||||
|
||||
export const listVMIso = () => {
|
||||
return http.post<VM.IsoItem[]>('/vms/iso/list');
|
||||
};
|
||||
export const searchVMIso = (params: SearchWithPage) => {
|
||||
return http.post<ResPage<VM.IsoItem>>('/vms/iso/search', params);
|
||||
};
|
||||
export const createVMIso = (params: VM.IsoCreate, currentNode?: string) => {
|
||||
return http.post('/vms/iso', params, TimeoutEnum.T_5M, currentNode ? { CurrentNode: currentNode } : undefined);
|
||||
};
|
||||
export const startVMIsoImport = (params: VM.IsoImportStart, currentNode?: string) => {
|
||||
return http.post<{ id: number }>(
|
||||
'/vms/iso/import/start',
|
||||
params,
|
||||
undefined,
|
||||
currentNode ? { CurrentNode: currentNode } : undefined,
|
||||
);
|
||||
};
|
||||
export const updateVMIsoImport = (params: VM.IsoImportUpdate, currentNode?: string) => {
|
||||
return http.post(
|
||||
'/vms/iso/import/update',
|
||||
params,
|
||||
undefined,
|
||||
currentNode ? { CurrentNode: currentNode } : undefined,
|
||||
);
|
||||
};
|
||||
export const updateVMIso = (params: VM.IsoUpdate, currentNode?: string) => {
|
||||
return http.post(
|
||||
'/vms/iso/update',
|
||||
params,
|
||||
TimeoutEnum.T_5M,
|
||||
currentNode ? { CurrentNode: currentNode } : undefined,
|
||||
);
|
||||
};
|
||||
export const deleteVMIso = (params: VM.IsoDelete) => {
|
||||
return http.post('/vms/iso/del', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const listVMNetworks = () => {
|
||||
return http.post<VM.NetworkItem[]>('/vms/networks/list');
|
||||
};
|
||||
export const listVMNetworkBridges = () => {
|
||||
return http.get<VM.NetworkBridgeItem[]>('/vms/networks/bridges');
|
||||
};
|
||||
export const listVMNetworkInterfaces = () => {
|
||||
return http.get<VM.NetworkInterfaceItem[]>('/vms/networks/interfaces');
|
||||
};
|
||||
export const createVMNetwork = (params: VM.NetworkCreate) => {
|
||||
return http.post('/vms/networks', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
export const updateVMNetwork = (params: VM.NetworkUpdate) => {
|
||||
return http.post('/vms/networks/update', params);
|
||||
};
|
||||
export const deleteVMNetwork = (params: VM.ResourceDelete) => {
|
||||
return http.post('/vms/networks/del', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const listVMStorages = () => {
|
||||
return http.post<VM.StorageItem[]>('/vms/storages/list');
|
||||
};
|
||||
export const createVMStorage = (params: VM.StorageCreate) => {
|
||||
return http.post('/vms/storages', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
export const updateVMStorage = (params: VM.StorageUpdate) => {
|
||||
return http.post('/vms/storages/update', params);
|
||||
};
|
||||
export const deleteVMStorage = (params: VM.ResourceDelete) => {
|
||||
return http.post('/vms/storages/del', params, TimeoutEnum.T_5M);
|
||||
};
|
||||
|
||||
export const scanVMOrphans = () => {
|
||||
return http.post<VM.OrphanItem[]>('/vms/orphans/scan');
|
||||
};
|
||||
export const cleanVMOrphans = (items: VM.OrphanItem[]) => {
|
||||
return http.post<VM.OrphanItem[]>('/vms/orphans/clean', { items }, TimeoutEnum.T_5M);
|
||||
};
|
||||
@@ -3962,7 +3962,6 @@ const message = {
|
||||
monitor:
|
||||
'Upgrade to Commercial Edition to view real-time website status, visitor trends, traffic sources, request logs, and more.',
|
||||
alert: 'Upgrade to Commercial Edition to receive SMS alerts, review alert logs, and stay on top of key events.',
|
||||
vm: 'Upgrade to the commercial edition to manage virtual machines, images, templates, virtual networks, and storage resources in 1Panel.',
|
||||
node: 'Upgrade to Commercial Edition to manage multiple Linux servers in 1Panel.',
|
||||
nodeApp: 'Upgrade to Commercial Edition to upgrade multi-node app versions without switching nodes manually.',
|
||||
nodeDashboard:
|
||||
@@ -7537,8 +7536,7 @@ const message = {
|
||||
submitSuccess: 'Sync task submitted.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'The Professional edition supports up to {0} virtual machines. Enterprise has no VM count limit.',
|
||||
countLimitHelper: 'The current edition supports up to {0} virtual machines.',
|
||||
vm: 'VM',
|
||||
title: 'Virtual Machines',
|
||||
healthCheck: 'Health Check',
|
||||
|
||||
@@ -4038,7 +4038,6 @@ const message = {
|
||||
setting: 'Actualizar a la edición comercial permite personalizar logo, mensaje de bienvenida y más.',
|
||||
monitor: 'La edición comercial permite ver estado web en tiempo real, tendencias, logs, etc.',
|
||||
alert: 'La edición comercial permite recibir alertas SMS y ver logs, control total de eventos clave.',
|
||||
vm: 'Actualice a la edición comercial para gestionar máquinas virtuales, imágenes, plantillas, redes virtuales y recursos de almacenamiento en 1Panel.',
|
||||
node: 'Actualizar a la edición comercial permite administrar varios servidores Linux con 1Panel.',
|
||||
nodeApp:
|
||||
'Actualizar a la edición comercial permite actualizar versiones de aplicaciones multinodo sin cambiar de nodo manualmente.',
|
||||
@@ -7657,8 +7656,7 @@ const message = {
|
||||
submitSuccess: 'La tarea de sincronización se ha enviado correctamente.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'La edición Professional permite crear hasta {0} máquinas virtuales. Enterprise no limita la cantidad.',
|
||||
countLimitHelper: 'La edición actual permite crear hasta {0} máquinas virtuales.',
|
||||
vm: 'Máquina virtual',
|
||||
title: 'Gestión de máquinas virtuales',
|
||||
healthCheck: 'Comprobación de estado',
|
||||
|
||||
@@ -3922,7 +3922,6 @@ const message = {
|
||||
monitor:
|
||||
'برای مشاهده وضعیت بلادرنگ وبسایت، روند بازدیدکنندگان، منابع ترافیک، لاگهای درخواست و موارد دیگر به نسخه تجاری ارتقاء دهید.',
|
||||
alert: 'برای دریافت هشدارهای پیامکی، مرور لاگهای هشدار و آگاهی از رویدادهای کلیدی به نسخه تجاری ارتقاء دهید.',
|
||||
vm: 'برای مدیریت ماشینهای مجازی، تصاویر، الگوها، شبکههای مجازی و منابع ذخیرهسازی در 1Panel به نسخه تجاری ارتقا دهید.',
|
||||
node: 'برای مدیریت چندین سرور لینوکس در 1Panel به نسخه تجاری ارتقاء دهید.',
|
||||
nodeApp: 'برای ارتقاء نسخه برنامههای چند گره بدون نیاز به جابجایی دستی بین گرهها به نسخه تجاری ارتقاء دهید.',
|
||||
nodeDashboard:
|
||||
@@ -7490,8 +7489,7 @@ const message = {
|
||||
submitSuccess: 'وظیفه همگامسازی ارسال شد.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'در نسخه Professional میتوانید حداکثر {0} ماشین مجازی ایجاد کنید. نسخه Enterprise محدودیتی در تعداد ندارد.',
|
||||
countLimitHelper: 'در نسخه فعلی میتوانید حداکثر {0} ماشین مجازی ایجاد کنید.',
|
||||
vm: 'ماشین مجازی',
|
||||
title: 'مدیریت ماشین مجازی',
|
||||
healthCheck: 'بررسی سلامت',
|
||||
|
||||
@@ -3940,7 +3940,6 @@ const message = {
|
||||
'商用版にアップグレードすると、Web サイトのリアルタイム状態、訪問者トレンド、流入元、リクエストログなどを確認できます。',
|
||||
alert: '商用版にアップグレードすると、SMS アラートの受信やアラートログの確認が可能になります。',
|
||||
app: '商用版にアップグレードすると、モバイルアプリでサービス情報や異常監視を確認できます。',
|
||||
vm: '商用版にアップグレードすると、1Panel で仮想マシン、イメージとテンプレート、仮想ネットワーク、ストレージリソースを一元管理できます。',
|
||||
node: '商用版にアップグレードすると、1Panel で複数の Linux サーバーを管理できます。',
|
||||
nodeApp:
|
||||
'商用版にアップグレードすると、ノードを手動で切り替えずにマルチノードアプリのバージョンを一括アップグレードできます。',
|
||||
@@ -7522,8 +7521,7 @@ const message = {
|
||||
submitSuccess: '同期タスクが送信されました。',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Professional 版では最大 {0} 台の仮想マシンを作成できます。Enterprise 版には台数制限がありません。',
|
||||
countLimitHelper: '現在のエディションでは最大 {0} 台の仮想マシンを作成できます。',
|
||||
vm: '仮想マシン',
|
||||
title: '仮想マシン管理',
|
||||
healthCheck: 'ヘルスチェック',
|
||||
|
||||
@@ -3866,7 +3866,6 @@ const message = {
|
||||
monitor:
|
||||
'상용 버전으로 업그레이드하면 웹사이트의 실시간 상태, 방문자 추세, 유입 경로, 요청 로그 등을 확인할 수 있습니다.',
|
||||
alert: '상용 버전으로 업그레이드하면 SMS 알림을 받고 알림 로그를 확인할 수 있습니다.',
|
||||
vm: '상용 버전으로 업그레이드하면 1Panel에서 가상 머신, 이미지와 템플릿, 가상 네트워크 및 스토리지 리소스를 통합 관리할 수 있습니다.',
|
||||
node: '상용 버전으로 업그레이드하면 1Panel에서 여러 Linux 서버를 관리할 수 있습니다.',
|
||||
nodeApp:
|
||||
'상용 버전으로 업그레이드하면 노드를 수동으로 전환하지 않고도 다중 노드 애플리케이션 버전을 일괄 업그레이드할 수 있습니다.',
|
||||
@@ -7371,8 +7370,7 @@ const message = {
|
||||
submitSuccess: '동기화 작업이 제출되었습니다.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Professional 버전에서는 가상 머신을 최대 {0}대까지 생성할 수 있습니다. Enterprise 버전은 대수 제한이 없습니다.',
|
||||
countLimitHelper: '현재 버전에서는 가상 머신을 최대 {0}대까지 생성할 수 있습니다.',
|
||||
vm: '가상 머신',
|
||||
title: '가상 머신 관리',
|
||||
healthCheck: '상태 점검',
|
||||
|
||||
@@ -3847,7 +3847,6 @@ const message = {
|
||||
monitor:
|
||||
'ອັບເກຣດເປັນເວີຊັນ Commercial ເພື່ອເບິ່ງສະຖານະເວັບໄຊແບບສົດໆ, ແນວໂນ້ມຜູ້ເຂົ້າຊົມ, ແຫຼ່ງທີ່ມາຂອງທຣາຟຟິກ, ບັນທຶກການຮ້ອງຂໍ ແລະ ອື່ນໆ.',
|
||||
alert: 'ອັບເກຣດເປັນເວີຊັນ Commercial ເພື່ອຮັບການແຈ້ງເຕືອນຜ່ານ SMS, ກວດສອບບັນທຶກການແຈ້ງເຕືອນ ແລະ ຕິດຕາມເຫດການສຳຄັນ.',
|
||||
vm: 'ອັບເກຣດເປັນລຸ້ນທຸລະກິດເພື່ອຈັດການເຄື່ອງສະເໝືອນ, ອິມເມຈ, ແມ່ແບບ, ເຄືອຂ່າຍສະເໝືອນ ແລະ ຊັບພະຍາກອນຈັດເກັບໃນ 1Panel.',
|
||||
node: 'ອັບເກຣດເປັນເວີຊັນ Commercial ເພື່ອຈັດການເຊີບເວີ Linux ຫຼາຍເຄື່ອງໃນ 1Panel.',
|
||||
nodeApp: 'ອັບເກຣດເປັນເວີຊັນ Commercial ເພື່ອອັບເກຣດເວີຊັນແອັບໃນຫຼາຍໂນດໂດຍບໍ່ຕ້ອງສະຫຼັບໂນດດ້ວຍຕົນເອງ.',
|
||||
nodeDashboard:
|
||||
@@ -7283,8 +7282,7 @@ const message = {
|
||||
submitSuccess: 'ສົ່ງງານຊິງໂຄຣໄນສຳເລັດ.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'ລຸ້ນ Professional ສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {0} ເຄື່ອງ. ລຸ້ນ Enterprise ບໍ່ຈຳກັດຈຳນວນ.',
|
||||
countLimitHelper: 'ລຸ້ນປັດຈຸບັນສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {0} ເຄື່ອງ.',
|
||||
vm: 'VM',
|
||||
title: 'ເຄື່ອງສະເໝືອນ',
|
||||
healthCheck: 'ກວດສອບສຸຂະພາບ',
|
||||
|
||||
@@ -4021,7 +4021,6 @@ const message = {
|
||||
monitor:
|
||||
'Naik taraf ke Edisi Komersial untuk melihat status laman web masa nyata, trend pelawat, sumber trafik, log permintaan dan lain-lain.',
|
||||
alert: 'Naik taraf ke Edisi Komersial untuk menerima amaran SMS dan melihat log amaran.',
|
||||
vm: 'Naik taraf kepada edisi komersial untuk mengurus mesin maya, imej, templat, rangkaian maya dan sumber storan dalam 1Panel.',
|
||||
node: 'Naik taraf ke Edisi Komersial untuk mengurus berbilang pelayan Linux dengan 1Panel.',
|
||||
nodeApp:
|
||||
'Naik taraf ke Edisi Komersial untuk menaik taraf versi aplikasi berbilang nod tanpa menukar nod secara manual.',
|
||||
@@ -7642,8 +7641,7 @@ const message = {
|
||||
submitSuccess: 'Tugas penyegerakan berjaya dihantar.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Edisi Professional membenarkan penciptaan sehingga {0} mesin maya. Edisi Enterprise tidak mengehadkan bilangannya.',
|
||||
countLimitHelper: 'Edisi semasa membenarkan penciptaan sehingga {0} mesin maya.',
|
||||
vm: 'Mesin Maya',
|
||||
title: 'Pengurusan Mesin Maya',
|
||||
healthCheck: 'Pemeriksaan kesihatan',
|
||||
|
||||
@@ -4034,7 +4034,6 @@ const message = {
|
||||
monitor:
|
||||
'Faça upgrade para a edição comercial para acompanhar o status do site em tempo real, tendências de visitantes, origens de tráfego, logs de requisições e mais.',
|
||||
alert: 'Faça upgrade para a edição comercial para receber alertas por SMS e consultar os logs de alertas.',
|
||||
vm: 'Atualize para a edição comercial para gerenciar máquinas virtuais, imagens, modelos, redes virtuais e recursos de armazenamento no 1Panel.',
|
||||
node: 'Atualize para a edição comercial para gerenciar vários servidores Linux com o 1Panel.',
|
||||
nodeApp:
|
||||
'Atualize para a edição comercial para atualizar versões de aplicativos multi-nó sem alternar manualmente entre os nós.',
|
||||
@@ -7687,8 +7686,7 @@ const message = {
|
||||
submitSuccess: 'Tarefa de sincronização enviada com sucesso.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'A edição Professional permite criar até {0} máquinas virtuais. A edição Enterprise não limita a quantidade.',
|
||||
countLimitHelper: 'A edição atual permite criar até {0} máquinas virtuais.',
|
||||
vm: 'Máquina virtual',
|
||||
title: 'Gerenciamento de Máquinas Virtuais',
|
||||
healthCheck: 'Verificação de integridade',
|
||||
|
||||
@@ -4005,7 +4005,6 @@ const message = {
|
||||
monitor:
|
||||
'Обновление до коммерческой версии позволяет просматривать статус веб-сайта в реальном времени, тенденции посещений, источники посетителей, логи запросов и другую информацию.',
|
||||
alert: 'Обновление до коммерческой версии позволяет получать информацию о тревогах через SMS и просматривать логи тревог, полностью контролировать различные ключевые события и обеспечивать беспроблемную работу системы',
|
||||
vm: 'Перейдите на коммерческую версию, чтобы управлять виртуальными машинами, образами, шаблонами, виртуальными сетями и ресурсами хранения в 1Panel.',
|
||||
node: 'Обновление до коммерческой редакции позволяет управлять несколькими серверами Linux с помощью 1Panel.',
|
||||
nodeApp:
|
||||
'Обновление до коммерческой редакции позволяет унифицированно обновлять версии приложений на нескольких узлах без необходимости ручного переключения узлов.',
|
||||
@@ -7659,8 +7658,7 @@ const message = {
|
||||
submitSuccess: 'Задача синхронизации успешно отправлена.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'В редакции Professional можно создать не более {0} виртуальных машин. В Enterprise количество не ограничено.',
|
||||
countLimitHelper: 'В текущей редакции можно создать не более {0} виртуальных машин.',
|
||||
vm: 'Виртуальная машина',
|
||||
title: 'Управление виртуальными машинами',
|
||||
healthCheck: 'Проверка состояния',
|
||||
|
||||
@@ -4021,7 +4021,6 @@ const message = {
|
||||
monitor:
|
||||
'Ticari sürüme yükselterek web sitesinin gerçek zamanlı durumunu, ziyaretçi eğilimlerini, kaynaklarını ve istek günlüklerini görüntüleyebilirsiniz.',
|
||||
alert: 'Ticari sürüme yükselterek SMS uyarıları alabilir, uyarı günlüklerini görüntüleyebilir ve kritik olayları takip edebilirsiniz.',
|
||||
vm: '1Panel ile sanal makineleri, imajları, şablonları, sanal ağları ve depolama kaynaklarını yönetmek için ticari sürüme yükseltin.',
|
||||
node: 'Ticari sürüme yükselterek 1Panel ile birden fazla Linux sunucusunu yönetebilirsiniz.',
|
||||
nodeApp:
|
||||
'Ticari sürüme yükselterek düğümleri manuel değiştirmeden çok düğümlü uygulama sürümlerini toplu olarak yükseltebilirsiniz.',
|
||||
@@ -7659,8 +7658,7 @@ const message = {
|
||||
submitSuccess: 'Senkronizasyon görevi başarıyla gönderildi.',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper:
|
||||
'Professional sürümü en fazla {0} sanal makine oluşturulmasına izin verir. Enterprise sürümünde sayı sınırı yoktur.',
|
||||
countLimitHelper: 'Mevcut sürüm en fazla {0} sanal makine oluşturulmasına izin verir.',
|
||||
vm: 'Sanal Makine',
|
||||
title: 'Sanal Makine Yönetimi',
|
||||
healthCheck: 'Sağlık kontrolü',
|
||||
|
||||
@@ -3699,7 +3699,6 @@ const message = {
|
||||
setting: '升級商業版可以自訂面板 Logo、歡迎簡介等資訊。',
|
||||
monitor: '升級商業版可以檢視網站的即時狀態、訪客趨勢、訪客來源、請求日誌等資訊。',
|
||||
alert: '升級商業版可透過簡訊接收告警資訊,並檢視告警日誌,全面掌控各類關鍵事件,確保系統執行無憂。',
|
||||
vm: '升級商業版,即可在 1Panel 中統一管理虛擬機、映像與範本、虛擬網路和儲存資源。',
|
||||
node: '升級商業版可以使用 1Panel 管理多台 Linux 伺服器。',
|
||||
nodeApp: '升級商業版可以統一升級多節點應用版本,無需手動切換節點。',
|
||||
nodeDashboard: '升級商業版可以集中管理多節點應用、網站、資料庫和計劃任務。',
|
||||
@@ -7004,7 +7003,7 @@ const message = {
|
||||
submitSuccess: '同步任務已提交。',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper: '專業版最多可建立 {0} 台虛擬機,企業版不限制數量。',
|
||||
countLimitHelper: '目前版本最多可建立 {0} 台虛擬機。',
|
||||
vm: '虛擬機',
|
||||
title: '虛擬機管理',
|
||||
healthCheck: '健康檢查',
|
||||
|
||||
@@ -3729,7 +3729,6 @@ const message = {
|
||||
setting: '升级商业版可以自定义面板 Logo、欢迎简介等信息。',
|
||||
monitor: '升级商业版可以查看网站的实时状态、访客趋势、访客来源、请求日志等信息。',
|
||||
alert: '升级商业版可通过短信接收告警信息,并查看告警日志,全面掌控各类关键事件,确保系统运行无忧。',
|
||||
vm: '升级商业版,即可在 1Panel 中统一管理虚拟机、镜像与模板、虚拟网络和存储资源。',
|
||||
node: '升级商业版可以使用 1Panel 管理多台 linux 服务器。',
|
||||
nodeApp: '升级商业版可以统一升级多节点应用版本,无需手动切换节点。',
|
||||
nodeDashboard: '升级商业版可以集中管理多节点应用、网站、数据库和计划任务。',
|
||||
@@ -7061,7 +7060,7 @@ const message = {
|
||||
submitSuccess: '同步任务已提交。',
|
||||
},
|
||||
vm: {
|
||||
countLimitHelper: '专业版最多可创建 {0} 台虚拟机,企业版不限制数量。',
|
||||
countLimitHelper: '当前版本最多可创建 {0} 台虚拟机。',
|
||||
vm: '虚拟机',
|
||||
title: '虚拟机管理',
|
||||
healthCheck: '健康检查',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Layout } from '@/routers/constant';
|
||||
import vmRouter from '@/routers/vm';
|
||||
|
||||
const hostRouter = {
|
||||
sort: 7,
|
||||
@@ -174,6 +175,7 @@ const hostRouter = {
|
||||
permission: 'host_ssh_view',
|
||||
},
|
||||
},
|
||||
vmRouter,
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
const vmRouter = {
|
||||
sort: 550,
|
||||
path: '/vm',
|
||||
name: 'VirtualMachine',
|
||||
component: () => import('@/views/vm/index.vue'),
|
||||
redirect: '/vm/overview',
|
||||
meta: {
|
||||
icon: 'p-disk-menu',
|
||||
title: 'xpack.vm.title',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'overview',
|
||||
name: 'VirtualMachineOverview',
|
||||
component: () => import('@/views/vm/overview/index.vue'),
|
||||
hidden: true,
|
||||
meta: {
|
||||
title: 'xpack.vm.overview',
|
||||
activeMenu: '/vm',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'list',
|
||||
name: 'VirtualMachineList',
|
||||
component: () => import('@/views/vm/list/index.vue'),
|
||||
hidden: true,
|
||||
meta: {
|
||||
title: 'xpack.vm.title',
|
||||
activeMenu: '/vm',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'isos',
|
||||
name: 'VirtualMachineISOs',
|
||||
component: () => import('@/views/vm/iso/iso.vue'),
|
||||
hidden: true,
|
||||
meta: {
|
||||
title: 'xpack.vm.iso',
|
||||
activeMenu: '/vm',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'templates',
|
||||
name: 'VirtualMachineTemplates',
|
||||
component: () => import('@/views/vm/template/index.vue'),
|
||||
hidden: true,
|
||||
meta: {
|
||||
title: 'xpack.vm.template',
|
||||
activeMenu: '/vm',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'networks',
|
||||
name: 'VirtualMachineNetworks',
|
||||
component: () => import('@/views/vm/network/index.vue'),
|
||||
hidden: true,
|
||||
meta: {
|
||||
title: 'xpack.vm.network',
|
||||
activeMenu: '/vm',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'storage-pools',
|
||||
name: 'VirtualMachineStoragePools',
|
||||
component: () => import('@/views/vm/storage/index.vue'),
|
||||
hidden: true,
|
||||
meta: {
|
||||
title: 'xpack.vm.storagePool',
|
||||
activeMenu: '/vm',
|
||||
requiresAuth: true,
|
||||
permission: 'xpack_vm_view',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default vmRouter;
|
||||
@@ -0,0 +1,5 @@
|
||||
export const isVMResourceUnavailable = (status?: string) =>
|
||||
['creating', 'failed', 'deleted'].includes(status?.trim().toLowerCase() || '');
|
||||
|
||||
export const isVMMemoryAllocated = (status: string) =>
|
||||
['running', 'blocked', 'paused', 'shutdown', 'pmsuspended'].includes(status);
|
||||
@@ -0,0 +1,80 @@
|
||||
<template>
|
||||
<DialogPro
|
||||
v-model="visible"
|
||||
:title="$t('commons.button.delete') + ' - ' + (source?.name || '')"
|
||||
size="small"
|
||||
:show-close="!loading"
|
||||
:close-on-press-escape="!loading"
|
||||
>
|
||||
<el-form label-position="top" :model="form">
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.force" :disabled="loading">{{ $t('app.forceDelete') }}</el-checkbox>
|
||||
<span class="input-help">{{ $t('app.forceDeleteHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="w-full">
|
||||
<span>{{ $t('commons.msg.operateConfirm') }}</span>
|
||||
<strong class="resource-delete-name">{{ source?.name }}</strong>
|
||||
<el-input v-model="form.confirmName" :placeholder="source?.name" :disabled="loading" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="loading" @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-permission
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="loading || form.confirmName !== source?.name"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DialogPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { deleteVMNetwork, deleteVMStorage } from '@/api/modules/vm';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import i18n from '@/lang';
|
||||
|
||||
const props = defineProps<{ resourceType: 'network' | 'storagePool' }>();
|
||||
const emit = defineEmits<{ (e: 'deleted'): void }>();
|
||||
const source = ref<{ id: number; name: string }>();
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const form = reactive({ force: false, confirmName: '' });
|
||||
|
||||
const open = (row: { id: number; name: string }) => {
|
||||
if (loading.value) return;
|
||||
source.value = row;
|
||||
form.force = false;
|
||||
form.confirmName = '';
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (loading.value || !source.value || form.confirmName !== source.value.name) return;
|
||||
const req: VM.ResourceDelete = { id: source.value.id, force: form.force };
|
||||
loading.value = true;
|
||||
try {
|
||||
await (props.resourceType === 'network' ? deleteVMNetwork(req) : deleteVMStorage(req));
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
emit('deleted');
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resource-delete-name {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,418 @@
|
||||
<template>
|
||||
<div class="vm-console">
|
||||
<div class="vm-console-toolbar">
|
||||
<el-dropdown
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:disabled="!connected"
|
||||
@command="(shortcut) => sendKeys(shortcut.key, shortcut.modifiers)"
|
||||
>
|
||||
<el-button size="small" type="primary" plain :disabled="!connected">
|
||||
{{ $t('xpack.vm.consoleShortcutKeys') }}
|
||||
<el-icon class="vm-console-shortcut-arrow"><ArrowDown /></el-icon>
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="shortcut in shortcutKeys"
|
||||
:key="shortcut.label"
|
||||
:command="shortcut"
|
||||
:divided="shortcut.key === 'F1'"
|
||||
>
|
||||
{{ shortcut.label }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<div class="vm-console-custom-keys">
|
||||
<span class="vm-console-toolbar-label">{{ $t('xpack.vm.consoleCustomKeys') }}</span>
|
||||
<el-checkbox-group v-model="selectedModifiers" size="small" :disabled="!connected">
|
||||
<el-checkbox-button v-for="key in modifierKeys" :key="key.code" :value="key.code">
|
||||
{{ key.label }}
|
||||
</el-checkbox-button>
|
||||
</el-checkbox-group>
|
||||
<el-select
|
||||
v-model="selectedKey"
|
||||
class="vm-console-key-select"
|
||||
size="small"
|
||||
filterable
|
||||
:disabled="!connected"
|
||||
:placeholder="$t('xpack.vm.consoleMainKey')"
|
||||
:aria-label="$t('xpack.vm.consoleMainKey')"
|
||||
>
|
||||
<el-option v-for="key in mainKeys" :key="key.code" :label="key.label" :value="key.code" />
|
||||
</el-select>
|
||||
<el-button size="small" type="primary" :disabled="!connected || !selectedKey" @click="sendKeys()">
|
||||
{{ $t('xpack.vm.consoleSendKeys') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vm-console-viewport">
|
||||
<div
|
||||
ref="screenRef"
|
||||
class="vm-console-screen"
|
||||
@keydown.capture="trackPressedKey"
|
||||
@keyup.capture="pressedKeys.delete($event.code)"
|
||||
@focusout="releasePressedKeys"
|
||||
/>
|
||||
<div v-if="statusText" class="vm-console-status" :class="{ 'is-error': statusError }">
|
||||
<span>{{ statusText }}</span>
|
||||
<el-button v-if="showReconnect" link type="primary" class="vm-console-reconnect" @click="reconnect">
|
||||
{{ $t('commons.button.reconnect') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, shallowRef } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ArrowDown } from '@element-plus/icons-vue';
|
||||
import { MsgError } from '@/utils/message';
|
||||
import i18n from '@/lang';
|
||||
|
||||
const props = defineProps({
|
||||
path: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
interface NoVNCRFB {
|
||||
scaleViewport: boolean;
|
||||
resizeSession: boolean;
|
||||
addEventListener(type: string, listener: EventListenerOrEventListenerObject): void;
|
||||
disconnect(): void;
|
||||
sendKey(keysym: number, code: string, down?: boolean): void;
|
||||
focus(): void;
|
||||
}
|
||||
|
||||
interface NoVNCRFBConstructor {
|
||||
new (target: HTMLElement, url: string): NoVNCRFB;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
const screenRef = ref<HTMLDivElement>();
|
||||
const rfb = shallowRef<NoVNCRFB>();
|
||||
const connected = ref(false);
|
||||
const selectedModifiers = ref<string[]>([]);
|
||||
const selectedKey = ref('');
|
||||
const statusText = ref('');
|
||||
const statusError = ref(false);
|
||||
const connecting = ref(false);
|
||||
const pressedKeys = new Set<string>();
|
||||
let connectionGeneration = 0;
|
||||
let screenReadyFrame: number | undefined;
|
||||
|
||||
const modifierKeys = [
|
||||
{ label: 'Ctrl', code: 'ControlLeft', keysym: 0xffe3 },
|
||||
{ label: 'Alt', code: 'AltLeft', keysym: 0xffe9 },
|
||||
{ label: 'Shift', code: 'ShiftLeft', keysym: 0xffe1 },
|
||||
{ label: 'Super / Win', code: 'MetaLeft', keysym: 0xffeb },
|
||||
];
|
||||
const mainKeys: { label: string; code: string; keysym: number; shiftKeysym?: number }[] = [
|
||||
{ label: 'Delete', code: 'Delete', keysym: 0xffff },
|
||||
{ label: 'Enter', code: 'Enter', keysym: 0xff0d },
|
||||
{ label: 'Tab', code: 'Tab', keysym: 0xff09 },
|
||||
{ label: 'Esc', code: 'Escape', keysym: 0xff1b },
|
||||
{ label: 'Backspace', code: 'Backspace', keysym: 0xff08 },
|
||||
{ label: 'Space', code: 'Space', keysym: 0x20 },
|
||||
{ label: 'Insert', code: 'Insert', keysym: 0xff63 },
|
||||
{ label: 'Home', code: 'Home', keysym: 0xff50 },
|
||||
{ label: 'End', code: 'End', keysym: 0xff57 },
|
||||
{ label: 'PageUp', code: 'PageUp', keysym: 0xff55 },
|
||||
{ label: 'PageDown', code: 'PageDown', keysym: 0xff56 },
|
||||
{ label: '↑', code: 'ArrowUp', keysym: 0xff52 },
|
||||
{ label: '↓', code: 'ArrowDown', keysym: 0xff54 },
|
||||
{ label: '←', code: 'ArrowLeft', keysym: 0xff51 },
|
||||
{ label: '→', code: 'ArrowRight', keysym: 0xff53 },
|
||||
...Array.from({ length: 12 }, (_, index) => ({
|
||||
label: `F${index + 1}`,
|
||||
code: `F${index + 1}`,
|
||||
keysym: 0xffbe + index,
|
||||
})),
|
||||
...Array.from({ length: 26 }, (_, index) => ({
|
||||
label: String.fromCharCode(65 + index),
|
||||
code: `Key${String.fromCharCode(65 + index)}`,
|
||||
keysym: 97 + index,
|
||||
shiftKeysym: 65 + index,
|
||||
})),
|
||||
...Array.from({ length: 10 }, (_, index) => ({
|
||||
label: String(index),
|
||||
code: `Digit${index}`,
|
||||
keysym: 48 + index,
|
||||
shiftKeysym: ')!@#$%^&*('[index].charCodeAt(0),
|
||||
})),
|
||||
];
|
||||
|
||||
const shortcutKeys = [
|
||||
{ label: 'Ctrl+Alt+Delete', key: 'Delete', modifiers: ['ControlLeft', 'AltLeft'] },
|
||||
{ label: 'Alt+Tab', key: 'Tab', modifiers: ['AltLeft'] },
|
||||
{ label: 'Ctrl+Shift+Esc', key: 'Escape', modifiers: ['ControlLeft', 'ShiftLeft'] },
|
||||
...Array.from({ length: 7 }, (_, index) => ({
|
||||
label: `Ctrl+Alt+F${index + 1}`,
|
||||
key: `F${index + 1}`,
|
||||
modifiers: ['ControlLeft', 'AltLeft'],
|
||||
})),
|
||||
];
|
||||
|
||||
const trackPressedKey = (event: KeyboardEvent) => {
|
||||
if (!connected.value || !event.code) return;
|
||||
if (event.code === 'CapsLock' && /mac|ipad|iphone|ipod/i.test(navigator.platform)) return;
|
||||
pressedKeys.add(event.code);
|
||||
};
|
||||
|
||||
const releasePressedKeys = (event: FocusEvent) => {
|
||||
const target = event.target as HTMLElement;
|
||||
const codes = [...pressedKeys].reverse();
|
||||
pressedKeys.clear();
|
||||
for (const code of codes) {
|
||||
target.dispatchEvent(new KeyboardEvent('keyup', { code, bubbles: true }));
|
||||
}
|
||||
};
|
||||
|
||||
const sendKeys = async (keyCode = selectedKey.value, modifierCodes = selectedModifiers.value) => {
|
||||
const client = rfb.value;
|
||||
if (!client || !connected.value) return;
|
||||
const key = mainKeys.find((item) => item.code === keyCode);
|
||||
if (!key) return;
|
||||
const modifiers = modifierKeys.filter((item) => modifierCodes.includes(item.code));
|
||||
const keysym = modifierCodes.includes('ShiftLeft') ? (key.shiftKeysym ?? key.keysym) : key.keysym;
|
||||
try {
|
||||
for (const modifier of modifiers) {
|
||||
client.sendKey(modifier.keysym, modifier.code, true);
|
||||
}
|
||||
client.sendKey(keysym, key.code);
|
||||
} finally {
|
||||
for (const modifier of modifiers.reverse()) {
|
||||
client.sendKey(modifier.keysym, modifier.code, false);
|
||||
}
|
||||
}
|
||||
await nextTick();
|
||||
if (rfb.value === client && connected.value) client.focus();
|
||||
};
|
||||
|
||||
const websocketURL = computed(() => {
|
||||
const path = String(props.path || route.query.path || '').trim();
|
||||
if (!path) {
|
||||
return '';
|
||||
}
|
||||
if (path.startsWith('ws://') || path.startsWith('wss://')) {
|
||||
return path;
|
||||
}
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||
const url = new URL(path);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return url.toString();
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
return `${protocol}//${window.location.host}/${path.replace(/^\//, '')}`;
|
||||
});
|
||||
|
||||
const setStatus = (text: string, isError = false) => {
|
||||
if (screenReadyFrame !== undefined) {
|
||||
window.cancelAnimationFrame(screenReadyFrame);
|
||||
screenReadyFrame = undefined;
|
||||
}
|
||||
if (text) connected.value = false;
|
||||
statusText.value = text;
|
||||
statusError.value = isError;
|
||||
};
|
||||
|
||||
const showReconnect = computed(() => Boolean(statusText.value) && !connecting.value);
|
||||
|
||||
const disconnect = () => {
|
||||
connected.value = false;
|
||||
pressedKeys.clear();
|
||||
connectionGeneration++;
|
||||
if (screenReadyFrame !== undefined) {
|
||||
window.cancelAnimationFrame(screenReadyFrame);
|
||||
screenReadyFrame = undefined;
|
||||
}
|
||||
const client = rfb.value;
|
||||
rfb.value = undefined;
|
||||
client?.disconnect();
|
||||
};
|
||||
|
||||
const connect = async () => {
|
||||
disconnect();
|
||||
const generation = connectionGeneration;
|
||||
connecting.value = true;
|
||||
await nextTick();
|
||||
await new Promise((resolve) => window.requestAnimationFrame(resolve));
|
||||
|
||||
if (generation !== connectionGeneration) {
|
||||
return;
|
||||
}
|
||||
const target = screenRef.value;
|
||||
const url = websocketURL.value;
|
||||
if (!target || !url) {
|
||||
connecting.value = false;
|
||||
const message = i18n.global.t('xpack.vm.consoleNotReady');
|
||||
setStatus(message, true);
|
||||
MsgError(message);
|
||||
return;
|
||||
}
|
||||
let RFB: NoVNCRFBConstructor;
|
||||
try {
|
||||
({ default: RFB } = (await import('@novnc/novnc')) as { default: NoVNCRFBConstructor });
|
||||
} catch {
|
||||
if (generation === connectionGeneration) {
|
||||
connecting.value = false;
|
||||
setStatus(i18n.global.t('xpack.vm.consoleConnectFailed'), true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (generation !== connectionGeneration || !screenRef.value) {
|
||||
return;
|
||||
}
|
||||
const client = new RFB(target, url);
|
||||
rfb.value = client;
|
||||
client.scaleViewport = true;
|
||||
client.resizeSession = true;
|
||||
const handleConnected = () => {
|
||||
if (rfb.value !== client) return;
|
||||
connecting.value = false;
|
||||
setStatus('');
|
||||
};
|
||||
client.addEventListener('connect', () => {
|
||||
if (rfb.value !== client) return;
|
||||
connected.value = true;
|
||||
handleConnected();
|
||||
});
|
||||
client.addEventListener('desktopname', handleConnected);
|
||||
const waitForScreenReady = () => {
|
||||
if (generation !== connectionGeneration || rfb.value !== client) return;
|
||||
const canvas = target.querySelector('canvas');
|
||||
if (canvas && canvas.width > 0 && canvas.height > 0) {
|
||||
handleConnected();
|
||||
return;
|
||||
}
|
||||
screenReadyFrame = window.requestAnimationFrame(waitForScreenReady);
|
||||
};
|
||||
screenReadyFrame = window.requestAnimationFrame(waitForScreenReady);
|
||||
client.addEventListener('disconnect', (event: CustomEvent<{ clean: boolean }>) => {
|
||||
if (rfb.value !== client) return;
|
||||
connecting.value = false;
|
||||
if (event.detail?.clean) {
|
||||
setStatus(i18n.global.t('xpack.vm.consoleDisconnected'));
|
||||
return;
|
||||
}
|
||||
setStatus(i18n.global.t('xpack.vm.consoleConnectFailed'), true);
|
||||
});
|
||||
client.addEventListener('securityfailure', (event: CustomEvent<{ reason?: string }>) => {
|
||||
if (rfb.value !== client) return;
|
||||
connecting.value = false;
|
||||
setStatus(event.detail?.reason || i18n.global.t('xpack.vm.consoleConnectFailed'), true);
|
||||
});
|
||||
client.addEventListener('credentialsrequired', () => {
|
||||
if (rfb.value !== client) return;
|
||||
connecting.value = false;
|
||||
setStatus(i18n.global.t('xpack.vm.consoleCredentialsRequired'), true);
|
||||
});
|
||||
};
|
||||
|
||||
const reconnect = () => {
|
||||
connect();
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
connect();
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
disconnect();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-console {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111827;
|
||||
}
|
||||
|
||||
.vm-console-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
background: var(--el-bg-color);
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.vm-console-shortcut-arrow {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.vm-console-custom-keys {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding-inline-start: 16px;
|
||||
border-inline-start: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.vm-console-toolbar-label {
|
||||
margin-right: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-console-key-select {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vm-console-custom-keys {
|
||||
flex-basis: 100%;
|
||||
padding-inline-start: 0;
|
||||
border-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-console-viewport {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.vm-console-screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vm-console-status {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
z-index: 1;
|
||||
max-width: calc(100% - 32px);
|
||||
padding: 8px 12px;
|
||||
color: #d1d5db;
|
||||
text-align: center;
|
||||
background: rgb(17 24 39 / 84%);
|
||||
border: 1px solid rgb(255 255 255 / 12%);
|
||||
border-radius: 4px;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.vm-console-reconnect {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.vm-console-status.is-error {
|
||||
color: #fecaca;
|
||||
border-color: rgb(248 113 113 / 45%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,48 @@
|
||||
<template>
|
||||
<div>
|
||||
<RouterButton :buttons="buttons" :show-expires-at="isProductPro" />
|
||||
<EnvironmentCheck :key="currentNode" v-slot="{ vmUnavailable, vmEnvironmentLoaded }">
|
||||
<RouterViewCache
|
||||
:route-props="{
|
||||
vmUnavailable,
|
||||
vmEnvironmentLoaded,
|
||||
}"
|
||||
/>
|
||||
</EnvironmentCheck>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import i18n from '@/lang';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import EnvironmentCheck from '@/views/vm/status/index.vue';
|
||||
|
||||
const { isProductPro, currentNode } = useGlobalStore();
|
||||
|
||||
const buttons = [
|
||||
{
|
||||
label: i18n.global.t('xpack.vm.overview'),
|
||||
path: '/vm/overview',
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('xpack.vm.vm'),
|
||||
path: '/vm/list',
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('xpack.vm.iso'),
|
||||
path: '/vm/isos',
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('xpack.vm.template'),
|
||||
path: '/vm/templates',
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('xpack.vm.network'),
|
||||
path: '/vm/networks',
|
||||
},
|
||||
{
|
||||
label: i18n.global.t('xpack.vm.storagePool'),
|
||||
path: '/vm/storage-pools',
|
||||
},
|
||||
];
|
||||
</script>
|
||||
@@ -0,0 +1,873 @@
|
||||
<template>
|
||||
<DrawerPro
|
||||
v-model="visible"
|
||||
:header="title"
|
||||
size="large"
|
||||
:auto-close="!submitLoading"
|
||||
:confirm-before-close="submitLoading"
|
||||
@before-close="handleBeforeClose"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model.trim="form.name" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('commons.table.type')" prop="type">
|
||||
<el-select v-model="form.type" filterable allow-create default-first-option clearable>
|
||||
<el-option
|
||||
v-for="item in isoTypeOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="formMode === 'create'" :label="$t('xpack.vm.isoSource')" prop="sourceMode">
|
||||
<el-radio-group v-model="form.sourceMode" @change="changeSourceMode">
|
||||
<el-radio-button label="server">{{ $t('database.hostSelect') }}</el-radio-button>
|
||||
<el-radio-button label="upload">{{ $t('database.localUpload') }}</el-radio-button>
|
||||
<el-radio-button label="download">{{ $t('xpack.vm.downloadFromServer') }}</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="formMode === 'edit' || form.sourceMode === 'server'">
|
||||
<el-form-item :label="$t('xpack.vm.isoPath')" prop="path">
|
||||
<el-input v-model.trim="form.path">
|
||||
<template #append>
|
||||
<el-button @click="openFileSelector" icon="Folder" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<template v-if="formMode === 'create' && form.sourceMode === 'upload'">
|
||||
<el-upload
|
||||
ref="uploadRef"
|
||||
drag
|
||||
:limit="1"
|
||||
:auto-upload="false"
|
||||
:show-file-list="true"
|
||||
:file-list="uploadFiles"
|
||||
accept=".iso"
|
||||
:on-change="handleUploadChange"
|
||||
:on-exceed="handleUploadExceed"
|
||||
:on-remove="handleUploadRemove"
|
||||
>
|
||||
<el-icon class="el-icon--upload"><UploadFilled /></el-icon>
|
||||
<div class="el-upload__text">{{ $t('file.dropHelper') }}</div>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<template v-if="formMode === 'create' && form.sourceMode === 'download'">
|
||||
<el-form-item :label="$t('file.downloadUrl')" prop="downloadUrl">
|
||||
<el-input v-model.trim="form.downloadUrl" @input="changeDownloadUrl" />
|
||||
<span v-if="downloadTargetPath" class="input-help">{{ downloadTargetPath }}</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item
|
||||
v-if="formMode === 'create' && ['upload', 'download'].includes(form.sourceMode)"
|
||||
:label="$t('xpack.vm.isoTargetDir')"
|
||||
prop="targetDir"
|
||||
>
|
||||
<el-input v-model.trim="form.targetDir" @blur="changeTargetDir" />
|
||||
<span class="input-help">{{ $t('xpack.vm.isoTargetDirHelper') }}</span>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="mt-4" :label="$t('commons.table.description')" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitLoading" @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-permission
|
||||
type="primary"
|
||||
:disabled="submitLoading || uploading || downloading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<div v-if="visible && submitLoading" class="vm-drawer-loading-mask">
|
||||
<div class="vm-submit-progress">
|
||||
<el-icon v-if="!showSubmitProgress" class="vm-submit-loading is-loading"><Loading /></el-icon>
|
||||
<div class="vm-submit-title">{{ submitStageTitle }}</div>
|
||||
<div v-if="submitFileName" class="vm-submit-file">{{ submitFileName }}</div>
|
||||
<el-progress
|
||||
v-if="showSubmitProgress"
|
||||
class="vm-submit-progress-bar"
|
||||
text-inside
|
||||
:stroke-width="18"
|
||||
:indeterminate="submitStage === 'downloading' && downloadPercent <= 0"
|
||||
:duration="1"
|
||||
:percentage="submitProgress"
|
||||
/>
|
||||
<div v-if="submitStage === 'uploading' && uploadTotalSize" class="vm-submit-size">
|
||||
{{ uploadProgressSize }} / {{ computeSize(uploadTotalSize) }}
|
||||
</div>
|
||||
<div class="vm-submit-helper">{{ $t('xpack.vm.transferInProgress') }}</div>
|
||||
<el-button v-if="canCancelTransfer" class="vm-submit-cancel" :loading="cancelling" @click="cancelTransfer">
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FileList ref="fileRef" @choose="selectFile" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
|
||||
import {
|
||||
type FormInstance,
|
||||
type FormRules,
|
||||
type UploadFile,
|
||||
type UploadFiles,
|
||||
type UploadInstance,
|
||||
type UploadProps,
|
||||
type UploadRawFile,
|
||||
} from 'element-plus';
|
||||
import { Loading, UploadFilled } from '@element-plus/icons-vue';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError, MsgSuccess, MsgWarning } from '@/utils/message';
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import {
|
||||
checkFile,
|
||||
chunkUploadFileData,
|
||||
createFile,
|
||||
stopChunkUpload,
|
||||
stopWgetFile,
|
||||
wgetFile,
|
||||
} from '@/api/modules/files';
|
||||
import { TimeoutEnum } from '@/enums/http-enum';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { isVMResourceUnavailable } from '@/utils/vm';
|
||||
import { createVMIso, listVMStorages, startVMIsoImport, updateVMIso, updateVMIsoImport } from '@/api/modules/vm';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { useGlobalStore } from '@/composables/useGlobalStore';
|
||||
import { checkStreamAuth } from '@/utils/stream-auth';
|
||||
import { computeSize } from '@/utils/size';
|
||||
|
||||
type FormMode = 'create' | 'edit';
|
||||
type SourceMode = 'server' | 'upload' | 'download';
|
||||
type SubmitStage = 'idle' | 'checking' | 'uploading' | 'downloading' | 'saving';
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
const { currentNode: globalCurrentNode } = useGlobalStore();
|
||||
const operateNode = globalCurrentNode.value || 'local';
|
||||
|
||||
const chunkSize = 5 * 1024 * 1024;
|
||||
const isoTypeOptions = [
|
||||
{ label: 'Debian', value: 'debian' },
|
||||
{ label: 'Ubuntu', value: 'ubuntu' },
|
||||
{ label: 'CentOS', value: 'centos' },
|
||||
{ label: 'Rocky Linux', value: 'rocky' },
|
||||
{ label: 'AlmaLinux', value: 'almalinux' },
|
||||
{ label: 'Fedora', value: 'fedora' },
|
||||
{ label: 'openSUSE', value: 'opensuse' },
|
||||
{ label: 'RHEL', value: 'rhel' },
|
||||
{ label: 'Oracle Linux', value: 'oraclelinux' },
|
||||
{ label: 'Arch Linux', value: 'archlinux' },
|
||||
{ label: 'Windows', value: 'windows' },
|
||||
{ label: 'FreeBSD', value: 'freebsd' },
|
||||
];
|
||||
|
||||
const visible = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const uploading = ref(false);
|
||||
const uploadPercent = ref(0);
|
||||
const downloading = ref(false);
|
||||
const downloadPercent = ref(0);
|
||||
const submitStage = ref<SubmitStage>('idle');
|
||||
const cancelling = ref(false);
|
||||
const cancelRequested = ref(false);
|
||||
const formMode = ref<FormMode>('create');
|
||||
const uploadTargetPath = ref('');
|
||||
const downloadTargetPath = ref('');
|
||||
const formRef = ref<FormInstance>();
|
||||
const fileRef = ref();
|
||||
const uploadRef = ref<UploadInstance>();
|
||||
const uploadFiles = ref<UploadFiles>([]);
|
||||
const storageItems = ref<VM.StorageItem[]>([]);
|
||||
let processSocket: WebSocket | null = null;
|
||||
let processTimer: ReturnType<typeof setInterval> | null = null;
|
||||
let processTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
let rejectProcess: ((error: Error) => void) | null = null;
|
||||
let uploadAbortController: AbortController | null = null;
|
||||
let uploadTaskKey = '';
|
||||
let downloadTaskKey = '';
|
||||
let importID = 0;
|
||||
const title = computed(() =>
|
||||
formMode.value === 'create' ? i18n.global.t('commons.button.create') : i18n.global.t('commons.button.edit'),
|
||||
);
|
||||
const showSubmitProgress = computed(() => ['uploading', 'downloading'].includes(submitStage.value));
|
||||
const canCancelTransfer = computed(() => ['uploading', 'downloading'].includes(submitStage.value));
|
||||
const submitProgress = computed(() => {
|
||||
if (submitStage.value === 'uploading') return uploadPercent.value;
|
||||
if (submitStage.value === 'downloading') return downloadPercent.value > 0 ? downloadPercent.value : 100;
|
||||
return 0;
|
||||
});
|
||||
const submitStageTitle = computed(() => {
|
||||
switch (submitStage.value) {
|
||||
case 'uploading':
|
||||
return i18n.global.t('commons.status.uploading');
|
||||
case 'downloading':
|
||||
return i18n.global.t('commons.status.downloading');
|
||||
case 'saving':
|
||||
return i18n.global.t('xpack.vm.addingIsoRecord');
|
||||
default:
|
||||
return i18n.global.t('commons.status.executing');
|
||||
}
|
||||
});
|
||||
const uploadTotalSize = computed(() => uploadFiles.value[0]?.size || 0);
|
||||
const uploadProgressSize = computed(() => computeSize((uploadTotalSize.value * uploadPercent.value) / 100));
|
||||
|
||||
const form = reactive({
|
||||
id: 0,
|
||||
type: '',
|
||||
name: '',
|
||||
path: '',
|
||||
targetDir: '',
|
||||
sourceMode: 'server' as SourceMode,
|
||||
downloadUrl: '',
|
||||
downloadName: '',
|
||||
description: '',
|
||||
});
|
||||
const submitFileName = computed(() => {
|
||||
if (submitStage.value === 'uploading') return uploadFiles.value[0]?.name || '';
|
||||
if (submitStage.value === 'downloading') return form.downloadName;
|
||||
return '';
|
||||
});
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [Rules.requiredInput],
|
||||
type: [Rules.requiredSelect],
|
||||
path: [Rules.requiredInput],
|
||||
downloadUrl: [Rules.requiredInput, { validator: validateDownloadUrl, trigger: 'blur' }],
|
||||
});
|
||||
|
||||
function validateDownloadUrl(_rule: unknown, value: string, callback: (e?: Error) => void) {
|
||||
const v = (value || '').trim();
|
||||
if (!v) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const u = new URL(v);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') {
|
||||
callback(new Error(i18n.global.t('file.wgetUrlInvalid')));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
} catch {
|
||||
callback(new Error(i18n.global.t('file.wgetUrlInvalid')));
|
||||
}
|
||||
}
|
||||
|
||||
const acceptParams = (row?: VM.IsoItem) => {
|
||||
resetForm();
|
||||
if (row) {
|
||||
formMode.value = 'edit';
|
||||
form.id = row.id;
|
||||
form.type = row.type;
|
||||
form.name = row.name;
|
||||
form.path = row.path;
|
||||
form.sourceMode = 'server';
|
||||
form.description = row.description || '';
|
||||
visible.value = true;
|
||||
return;
|
||||
}
|
||||
formMode.value = 'create';
|
||||
visible.value = true;
|
||||
loadVMStorages();
|
||||
};
|
||||
|
||||
const changeSourceMode = () => {
|
||||
form.path = '';
|
||||
form.downloadUrl = '';
|
||||
form.downloadName = '';
|
||||
uploadTargetPath.value = '';
|
||||
downloadTargetPath.value = '';
|
||||
uploadFiles.value = [];
|
||||
uploadRef.value?.clearFiles();
|
||||
formRef.value?.clearValidate(['path', 'downloadUrl']);
|
||||
if (['upload', 'download'].includes(form.sourceMode) && !form.targetDir) {
|
||||
loadVMStorages().then(() => {
|
||||
setDefaultTargetDir();
|
||||
updateTargetPaths();
|
||||
});
|
||||
}
|
||||
updateTargetPaths();
|
||||
};
|
||||
|
||||
const openFileSelector = () => {
|
||||
fileRef.value?.acceptParams({ path: form.path ? dirname(form.path) : '/', dir: false });
|
||||
};
|
||||
|
||||
const selectFile = (path: string) => {
|
||||
form.path = path;
|
||||
if (!form.name) {
|
||||
form.name = filename(path);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadChange: UploadProps['onChange'] = (file: UploadFile, files: UploadFiles) => {
|
||||
uploadFiles.value = files.slice(-1);
|
||||
if (!form.name) {
|
||||
form.name = file.name;
|
||||
}
|
||||
updateUploadTargetPath();
|
||||
};
|
||||
|
||||
const handleUploadExceed: UploadProps['onExceed'] = (files) => {
|
||||
uploadRef.value?.clearFiles();
|
||||
const file = files[0] as UploadRawFile;
|
||||
uploadRef.value?.handleStart(file);
|
||||
};
|
||||
|
||||
const handleUploadRemove = () => {
|
||||
uploadFiles.value = [];
|
||||
uploadTargetPath.value = '';
|
||||
};
|
||||
|
||||
const changeDownloadUrl = (url: string) => {
|
||||
form.downloadName = getFileNameFromUrl(url);
|
||||
if (!form.name) {
|
||||
form.name = form.downloadName;
|
||||
}
|
||||
updateDownloadTargetPath();
|
||||
};
|
||||
|
||||
const changeTargetDir = () => {
|
||||
if (!form.targetDir) {
|
||||
uploadTargetPath.value = '';
|
||||
downloadTargetPath.value = '';
|
||||
return;
|
||||
}
|
||||
form.targetDir = normalizeDir(form.targetDir);
|
||||
updateTargetPaths();
|
||||
};
|
||||
|
||||
const updateTargetPaths = () => {
|
||||
updateUploadTargetPath();
|
||||
updateDownloadTargetPath();
|
||||
};
|
||||
|
||||
const updateUploadTargetPath = () => {
|
||||
const file = uploadFiles.value[0];
|
||||
uploadTargetPath.value = file?.name && form.targetDir ? joinPath(form.targetDir, file.name) : '';
|
||||
};
|
||||
|
||||
const updateDownloadTargetPath = () => {
|
||||
downloadTargetPath.value = form.downloadName && form.targetDir ? joinPath(form.targetDir, form.downloadName) : '';
|
||||
};
|
||||
|
||||
const upload = async () => {
|
||||
const file = uploadFiles.value[0];
|
||||
if (!file?.raw) {
|
||||
return;
|
||||
}
|
||||
submitStage.value = 'uploading';
|
||||
uploading.value = true;
|
||||
uploadPercent.value = 0;
|
||||
uploadAbortController = new AbortController();
|
||||
let completed = false;
|
||||
try {
|
||||
await uploadLargeFile(file);
|
||||
completed = true;
|
||||
} finally {
|
||||
if (!completed && uploadTaskKey) {
|
||||
await stopChunkUpload(uploadTaskKey, operateNode).catch(() => undefined);
|
||||
}
|
||||
uploading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const uploadLargeFile = async (file: UploadFile) => {
|
||||
const raw = file.raw as UploadRawFile;
|
||||
const count = Math.ceil((file.size || 0) / chunkSize);
|
||||
for (let index = 0; index < count; index++) {
|
||||
const start = index * chunkSize;
|
||||
const end = Math.min(start + chunkSize, file.size || 0);
|
||||
const formData = new FormData();
|
||||
formData.append('filename', file.name);
|
||||
formData.append('uploadID', uploadTaskKey);
|
||||
formData.append('path', normalizeDir(form.targetDir));
|
||||
formData.append('chunk', raw.slice(start, end));
|
||||
formData.append('chunkIndex', index.toString());
|
||||
formData.append('chunkCount', count.toString());
|
||||
formData.append('overwrite', 'True');
|
||||
await chunkUploadFileData(formData, {
|
||||
headers: { CurrentNode: operateNode },
|
||||
onUploadProgress: (progressEvent) => {
|
||||
uploadPercent.value = Math.round(
|
||||
((index + progressEvent.loaded / (progressEvent.total || 1)) * 100) / count,
|
||||
);
|
||||
},
|
||||
timeout: TimeoutEnum.T_60S,
|
||||
signal: uploadAbortController?.signal,
|
||||
});
|
||||
}
|
||||
form.path = uploadTargetPath.value;
|
||||
};
|
||||
|
||||
const downloadServerIso = async () => {
|
||||
submitStage.value = 'downloading';
|
||||
downloading.value = true;
|
||||
downloadPercent.value = 0;
|
||||
try {
|
||||
await ensureTargetDirExists(normalizeDir(form.targetDir));
|
||||
const res = await wgetFile(
|
||||
{
|
||||
url: form.downloadUrl.trim(),
|
||||
path: normalizeDir(form.targetDir),
|
||||
name: form.downloadName,
|
||||
ignoreCertificate: true,
|
||||
useProxy: false,
|
||||
},
|
||||
operateNode,
|
||||
);
|
||||
downloadTaskKey = res.data.key;
|
||||
if (importID) {
|
||||
await updateVMIsoImport(
|
||||
{
|
||||
id: importID,
|
||||
status: 'downloading',
|
||||
tempFileID: downloadTaskKey,
|
||||
tempFileType: 'download',
|
||||
},
|
||||
operateNode,
|
||||
);
|
||||
}
|
||||
if (cancelRequested.value) {
|
||||
await stopWgetFile(downloadTaskKey, operateNode);
|
||||
throw new Error('download cancelled');
|
||||
}
|
||||
await waitDownloadComplete(res.data.key);
|
||||
form.path = downloadTargetPath.value;
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
closeProcessSocket();
|
||||
}
|
||||
};
|
||||
|
||||
const cancelTransfer = async () => {
|
||||
if (cancelling.value) {
|
||||
return;
|
||||
}
|
||||
cancelling.value = true;
|
||||
cancelRequested.value = true;
|
||||
try {
|
||||
if (submitStage.value === 'uploading') {
|
||||
uploadAbortController?.abort();
|
||||
return;
|
||||
}
|
||||
if (submitStage.value === 'downloading' && downloadTaskKey) {
|
||||
await stopWgetFile(downloadTaskKey, operateNode);
|
||||
}
|
||||
} finally {
|
||||
if (submitStage.value === 'downloading') {
|
||||
closeProcessSocket();
|
||||
}
|
||||
cancelling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const waitDownloadComplete = async (key: string) => {
|
||||
if (!key) {
|
||||
throw new Error(i18n.global.t('commons.msg.operationFailed'));
|
||||
}
|
||||
const href = window.location.href;
|
||||
const protocol = href.split('//')[0] === 'http:' ? 'ws' : 'wss';
|
||||
const ipLocal = href.split('//')[1].split('/')[0];
|
||||
const currentNode = operateNode;
|
||||
const url = `${protocol}://${ipLocal}/api/v2/files/wget/process?operateNode=${encodeURIComponent(currentNode)}`;
|
||||
const authError = await checkStreamAuth(url, currentNode);
|
||||
if (authError) {
|
||||
MsgError(authError);
|
||||
throw new Error(authError);
|
||||
}
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
let emptyCount = 0;
|
||||
let hasProgress = false;
|
||||
let settled = false;
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
rejectProcess = null;
|
||||
clearProcessTimer();
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
};
|
||||
const resetTimeout = () => {
|
||||
if (processTimeout) clearTimeout(processTimeout);
|
||||
processTimeout = setTimeout(() => finish(new Error(i18n.global.t('commons.msg.operationFailed'))), 60_000);
|
||||
};
|
||||
closeProcessSocket();
|
||||
rejectProcess = (error) => finish(error);
|
||||
resetTimeout();
|
||||
processSocket = new WebSocket(url);
|
||||
processSocket.onopen = () => {
|
||||
resetTimeout();
|
||||
processTimer = setInterval(() => {
|
||||
if (processSocket?.readyState === WebSocket.OPEN) {
|
||||
processSocket.send(JSON.stringify({ type: 'wget', keys: [key] }));
|
||||
}
|
||||
}, 1000);
|
||||
};
|
||||
processSocket.onmessage = (message) => {
|
||||
resetTimeout();
|
||||
let list;
|
||||
try {
|
||||
list = JSON.parse(message.data || '[]');
|
||||
} catch {
|
||||
finish(new Error(i18n.global.t('commons.msg.operationFailed')));
|
||||
return;
|
||||
}
|
||||
const progress = list?.[0];
|
||||
if (progress) {
|
||||
if (progress.status === 'Failed' || progress.status === 'Canceled') {
|
||||
const error = progress.error || i18n.global.t('commons.msg.operationFailed');
|
||||
if (!cancelRequested.value) {
|
||||
MsgError(error);
|
||||
}
|
||||
finish(new Error(error));
|
||||
return;
|
||||
}
|
||||
hasProgress = true;
|
||||
emptyCount = 0;
|
||||
downloadPercent.value = Math.min(Math.round(Number(progress.percent) || 0), 100);
|
||||
if (Number(progress.percent) >= 100) {
|
||||
finish();
|
||||
}
|
||||
return;
|
||||
}
|
||||
emptyCount++;
|
||||
if (hasProgress && emptyCount >= 3) {
|
||||
finish(new Error(i18n.global.t('commons.msg.operationFailed')));
|
||||
}
|
||||
};
|
||||
processSocket.onerror = () => finish(new Error(i18n.global.t('commons.msg.operationFailed')));
|
||||
processSocket.onclose = () => {
|
||||
finish(new Error(i18n.global.t('commons.msg.operationFailed')));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
await formRef.value.validate();
|
||||
submitLoading.value = true;
|
||||
submitStage.value = 'checking';
|
||||
try {
|
||||
if (formMode.value === 'create') {
|
||||
if (form.sourceMode === 'upload') {
|
||||
if (!form.targetDir) {
|
||||
MsgError(i18n.global.t('commons.rule.requiredInput'));
|
||||
return;
|
||||
}
|
||||
if (!uploadFiles.value[0]?.raw) {
|
||||
MsgError(i18n.global.t('commons.rule.requiredInput'));
|
||||
return;
|
||||
}
|
||||
form.targetDir = normalizeDir(form.targetDir);
|
||||
updateUploadTargetPath();
|
||||
uploadTaskKey = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
await startImport('uploading', uploadTargetPath.value, uploadTaskKey, 'upload');
|
||||
await upload();
|
||||
} else if (form.sourceMode === 'download') {
|
||||
if (!form.targetDir || !form.downloadUrl || !form.downloadName) {
|
||||
MsgError(i18n.global.t('commons.rule.requiredInput'));
|
||||
return;
|
||||
}
|
||||
form.targetDir = normalizeDir(form.targetDir);
|
||||
updateDownloadTargetPath();
|
||||
await startImport('downloading', downloadTargetPath.value);
|
||||
await downloadServerIso();
|
||||
}
|
||||
}
|
||||
|
||||
submitStage.value = 'saving';
|
||||
if (formMode.value === 'create') {
|
||||
if (importID) {
|
||||
await updateVMIsoImport({ id: importID, status: 'ready' }, operateNode);
|
||||
} else {
|
||||
await createVMIso(
|
||||
{
|
||||
type: form.type,
|
||||
name: form.name,
|
||||
path: form.path,
|
||||
description: form.description,
|
||||
},
|
||||
operateNode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await updateVMIso(
|
||||
{
|
||||
id: form.id,
|
||||
type: form.type,
|
||||
name: form.name,
|
||||
path: form.path,
|
||||
description: form.description,
|
||||
},
|
||||
operateNode,
|
||||
);
|
||||
}
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
emit('search');
|
||||
} catch (error) {
|
||||
if (importID) {
|
||||
await updateVMIsoImport(
|
||||
{
|
||||
id: importID,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : i18n.global.t('commons.msg.operationFailed'),
|
||||
},
|
||||
operateNode,
|
||||
).catch(() => undefined);
|
||||
}
|
||||
if (!cancelRequested.value) {
|
||||
throw error;
|
||||
}
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
submitStage.value = 'idle';
|
||||
cancelling.value = false;
|
||||
cancelRequested.value = false;
|
||||
uploadAbortController = null;
|
||||
uploadTaskKey = '';
|
||||
downloadTaskKey = '';
|
||||
importID = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const handleBeforeClose = () => {
|
||||
MsgWarning(i18n.global.t('xpack.vm.transferInProgress'));
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0;
|
||||
form.type = '';
|
||||
form.name = '';
|
||||
form.path = '';
|
||||
form.targetDir = '';
|
||||
form.sourceMode = 'server';
|
||||
form.downloadUrl = '';
|
||||
form.downloadName = '';
|
||||
form.description = '';
|
||||
uploadTargetPath.value = '';
|
||||
downloadTargetPath.value = '';
|
||||
uploadFiles.value = [];
|
||||
uploadPercent.value = 0;
|
||||
downloadPercent.value = 0;
|
||||
submitStage.value = 'idle';
|
||||
cancelling.value = false;
|
||||
cancelRequested.value = false;
|
||||
uploadAbortController = null;
|
||||
uploadTaskKey = '';
|
||||
downloadTaskKey = '';
|
||||
importID = 0;
|
||||
closeProcessSocket();
|
||||
uploadRef.value?.clearFiles();
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const loadVMStorages = async () => {
|
||||
if (storageItems.value.length) {
|
||||
return;
|
||||
}
|
||||
const res = await listVMStorages();
|
||||
storageItems.value = (res.data || []).filter((item) => item.path && resourceExists(item.status));
|
||||
};
|
||||
|
||||
const resourceExists = (status: string) => !isVMResourceUnavailable(status);
|
||||
|
||||
const normalizeDir = (path: string) => {
|
||||
const rawPath = (path || '/').replace(/\/+/g, '/').replace(/\/+$/, '') || '/';
|
||||
const isAbsolute = rawPath.startsWith('/');
|
||||
const parts: string[] = [];
|
||||
for (const item of rawPath.split('/')) {
|
||||
if (!item || item === '.') {
|
||||
continue;
|
||||
}
|
||||
if (item === '..') {
|
||||
if (parts.length) {
|
||||
parts.pop();
|
||||
} else if (!isAbsolute) {
|
||||
parts.push(item);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
parts.push(item);
|
||||
}
|
||||
const normalized = `${isAbsolute ? '/' : ''}${parts.join('/')}`;
|
||||
return normalized || (isAbsolute ? '/' : '.');
|
||||
};
|
||||
const joinPath = (dir: string, name: string) => `${normalizeDir(dir)}/${name}`.replace(/^\/\/+/, '/');
|
||||
const filename = (path: string) => path.split('/').filter(Boolean).pop() || path;
|
||||
const dirname = (path: string) => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
parts.pop();
|
||||
return `/${parts.join('/')}`;
|
||||
};
|
||||
|
||||
const getFileNameFromUrl = (url: string) => {
|
||||
try {
|
||||
const paths = new URL(url).pathname.split('/').filter(Boolean);
|
||||
return decodeURIComponent(paths.pop() || '');
|
||||
} catch {
|
||||
const paths = url.split('?')[0].split('/').filter(Boolean);
|
||||
return paths.pop() || '';
|
||||
}
|
||||
};
|
||||
|
||||
const setDefaultTargetDir = () => {
|
||||
const defaultStorage = storageItems.value.find((item) => item.name === 'default') || storageItems.value[0];
|
||||
form.targetDir = defaultStorage?.path ? joinPath(defaultStorage.path, 'vm-iso') : '';
|
||||
};
|
||||
|
||||
const ensureTargetDirExists = async (targetDir: string) => {
|
||||
const res = await checkFile(targetDir, false, operateNode);
|
||||
if (!res.data) {
|
||||
await createFile({ path: targetDir, isDir: true }, operateNode);
|
||||
}
|
||||
};
|
||||
|
||||
const startImport = async (
|
||||
status: 'uploading' | 'downloading',
|
||||
path: string,
|
||||
tempFileID = '',
|
||||
tempFileType: 'upload' | 'download' | undefined = undefined,
|
||||
) => {
|
||||
const res = await startVMIsoImport(
|
||||
{
|
||||
type: form.type,
|
||||
name: form.name,
|
||||
path,
|
||||
status,
|
||||
tempFileID,
|
||||
tempFileType,
|
||||
description: form.description,
|
||||
},
|
||||
operateNode,
|
||||
);
|
||||
importID = res.data.id;
|
||||
if (cancelRequested.value) {
|
||||
throw new Error('import cancelled');
|
||||
}
|
||||
};
|
||||
|
||||
const clearProcessTimer = () => {
|
||||
if (processTimer) {
|
||||
clearInterval(processTimer);
|
||||
processTimer = null;
|
||||
}
|
||||
if (processTimeout) {
|
||||
clearTimeout(processTimeout);
|
||||
processTimeout = null;
|
||||
}
|
||||
};
|
||||
|
||||
const closeProcessSocket = () => {
|
||||
rejectProcess?.(new Error(i18n.global.t('commons.msg.operationFailed')));
|
||||
rejectProcess = null;
|
||||
clearProcessTimer();
|
||||
const socket = processSocket;
|
||||
processSocket = null;
|
||||
if (socket) {
|
||||
socket.onopen = null;
|
||||
socket.onmessage = null;
|
||||
socket.onerror = null;
|
||||
socket.onclose = null;
|
||||
if (socket.readyState < WebSocket.CLOSING) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (submitLoading.value) {
|
||||
cancelRequested.value = true;
|
||||
}
|
||||
if (canCancelTransfer.value) {
|
||||
void cancelTransfer();
|
||||
}
|
||||
closeProcessSocket();
|
||||
});
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-drawer-loading-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000;
|
||||
width: 50%;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: color-mix(in srgb, var(--el-mask-color) 82%, transparent);
|
||||
}
|
||||
|
||||
.vm-submit-progress {
|
||||
width: min(420px, 100%);
|
||||
padding: 28px 30px;
|
||||
text-align: center;
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-light);
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--el-box-shadow-light);
|
||||
}
|
||||
|
||||
.vm-submit-loading {
|
||||
margin-bottom: 14px;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
.vm-submit-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.vm-submit-file {
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-submit-progress-bar {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.vm-submit-size,
|
||||
.vm-submit-helper {
|
||||
margin-top: 10px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-submit-helper {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.vm-submit-cancel {
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,420 @@
|
||||
<template>
|
||||
<LayoutContent :title="$t('xpack.vm.iso')">
|
||||
<template #leftToolBar>
|
||||
<el-button v-permission type="primary" :disabled="vmUnavailable" @click="openCreate">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template #rightToolBar>
|
||||
<TableSearch v-model:searchName="searchName" @search="search" />
|
||||
<TableRefresh @search="search" />
|
||||
</template>
|
||||
<template #main>
|
||||
<div v-loading="loading" class="iso-card-content">
|
||||
<div class="iso-card-list" v-if="items.length">
|
||||
<article v-for="item in items" :key="item.id" class="iso-card">
|
||||
<div class="iso-card__header">
|
||||
<div class="iso-card__title">
|
||||
<span class="iso-card__name" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
<el-tag :type="isoStatusType(item)" round size="small" effect="plain">
|
||||
{{ isoStatusLabel(item) }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="iso-card__sub" :title="item.path">{{ item.path || '-' }}</div>
|
||||
<div class="iso-card__detail">
|
||||
<div class="iso-card__detail-item">
|
||||
<span>{{ $t('commons.table.type') }}</span>
|
||||
<strong :title="item.type">{{ formatIsoType(item.type) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="iso-card__detail">
|
||||
<div class="iso-card__detail-item">
|
||||
<span>{{ $t('xpack.vm.isoSize') }}</span>
|
||||
<strong :title="item.description">
|
||||
{{ item.isExist ? computeSize(item.size) : '-' }}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="iso-card__detail">
|
||||
<div class="iso-card__detail-item">
|
||||
<span>{{ $t('commons.table.date') }}</span>
|
||||
<strong :title="item.description">{{ formatDate(item.createdAt) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="iso-card__detail iso-card__detail--last">
|
||||
<div class="iso-card__detail-item">
|
||||
<span>{{ $t('commons.table.description') }}</span>
|
||||
<strong :title="item.description">{{ item.description || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.status === 'failed' && item.error" class="iso-card__error" :title="item.error">
|
||||
{{ item.error }}
|
||||
</div>
|
||||
<div class="iso-card__actions">
|
||||
<el-button
|
||||
v-permission
|
||||
link
|
||||
type="primary"
|
||||
:disabled="isImporting(item)"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
{{ $t('commons.button.edit') }}
|
||||
</el-button>
|
||||
<el-button v-permission link type="primary" @click="openDelete(item)">
|
||||
{{ $t('commons.button.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else-if="!loading" :description="$t('commons.msg.noneData')" />
|
||||
</div>
|
||||
<div v-if="paginationConfig.total > 0" class="iso-card-pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="paginationConfig.currentPage"
|
||||
v-model:page-size="paginationConfig.pageSize"
|
||||
:total="paginationConfig.total"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@size-change="handleSizeChange"
|
||||
@current-change="search"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<OpDialog ref="deleteRef" @search="handleDeleteSuccess" @cancel="resetDelete">
|
||||
<template #content>
|
||||
<div class="iso-delete-dialog__option">
|
||||
<el-checkbox v-model="deleteReq.deleteFile" :disabled="!deleteRow?.isExist">
|
||||
{{ $t('xpack.vm.deleteSourceFile') }}
|
||||
</el-checkbox>
|
||||
<div class="iso-delete-dialog__helper">
|
||||
{{ $t('xpack.vm.deleteSourceFileHelper') }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</OpDialog>
|
||||
|
||||
<Operate ref="operateRef" @search="search" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import i18n from '@/lang';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { deleteVMIso, searchVMIso } from '@/api/modules/vm';
|
||||
import { stopChunkUpload, stopWgetFile } from '@/api/modules/files';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { dateFormat } from '@/utils/date';
|
||||
import Operate from '@/views/vm/iso/create/index.vue';
|
||||
import OpDialog from '@/components/del-dialog/index.vue';
|
||||
|
||||
const isoTypeOptions = [
|
||||
{ label: 'Debian', value: 'debian' },
|
||||
{ label: 'Ubuntu', value: 'ubuntu' },
|
||||
{ label: 'CentOS', value: 'centos' },
|
||||
{ label: 'Rocky Linux', value: 'rocky' },
|
||||
{ label: 'AlmaLinux', value: 'almalinux' },
|
||||
{ label: 'Fedora', value: 'fedora' },
|
||||
{ label: 'openSUSE', value: 'opensuse' },
|
||||
{ label: 'RHEL', value: 'rhel' },
|
||||
{ label: 'Oracle Linux', value: 'oraclelinux' },
|
||||
{ label: 'Arch Linux', value: 'archlinux' },
|
||||
{ label: 'Windows', value: 'windows' },
|
||||
{ label: 'FreeBSD', value: 'freebsd' },
|
||||
];
|
||||
|
||||
const props = defineProps<{
|
||||
vmUnavailable: boolean;
|
||||
vmEnvironmentLoaded: boolean;
|
||||
}>();
|
||||
const vmUnavailable = computed(() => props.vmUnavailable);
|
||||
const loading = ref(false);
|
||||
const searchName = ref('');
|
||||
const items = ref<VM.IsoItem[]>([]);
|
||||
const operateRef = ref();
|
||||
const deleteRef = ref();
|
||||
const deleteRow = ref<VM.IsoItem>();
|
||||
const deleteReq = reactive<VM.IsoDelete>({
|
||||
id: 0,
|
||||
deleteFile: false,
|
||||
});
|
||||
|
||||
const paginationConfig = reactive({
|
||||
currentPage: 1,
|
||||
pageSize: 20,
|
||||
total: 0,
|
||||
});
|
||||
|
||||
const search = async () => {
|
||||
if (!props.vmEnvironmentLoaded || vmUnavailable.value) {
|
||||
items.value = [];
|
||||
paginationConfig.total = 0;
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await searchVMIso({
|
||||
page: paginationConfig.currentPage,
|
||||
pageSize: paginationConfig.pageSize,
|
||||
info: searchName.value,
|
||||
});
|
||||
items.value = res.data.items || [];
|
||||
paginationConfig.total = res.data.total || 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSizeChange = () => {
|
||||
paginationConfig.currentPage = 1;
|
||||
search();
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => (value ? dateFormat(null, null, value) : '-');
|
||||
const formatIsoType = (value: string) => isoTypeOptions.find((item) => item.value === value)?.label || value || '-';
|
||||
const isoStatusType = (item: VM.IsoItem) => {
|
||||
if (item.status === 'failed' || (!item.isExist && item.status === 'ready')) return 'danger';
|
||||
if (item.status === 'uploading' || item.status === 'downloading') return 'warning';
|
||||
return 'success';
|
||||
};
|
||||
const isoStatusLabel = (item: VM.IsoItem) => {
|
||||
if (item.status === 'uploading') return i18n.global.t('commons.status.uploading');
|
||||
if (item.status === 'downloading') return i18n.global.t('commons.status.downloading');
|
||||
if (item.status === 'failed') return i18n.global.t('commons.status.failed');
|
||||
return item.isExist ? i18n.global.t('commons.status.ready') : i18n.global.t('commons.status.deleted');
|
||||
};
|
||||
const isImporting = (item: VM.IsoItem) => ['uploading', 'downloading'].includes(item.status);
|
||||
|
||||
const openCreate = () => {
|
||||
if (vmUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
operateRef.value?.acceptParams();
|
||||
};
|
||||
|
||||
const openEdit = (row: VM.IsoItem) => operateRef.value?.acceptParams(row);
|
||||
|
||||
const openDelete = (row: VM.IsoItem) => {
|
||||
deleteRow.value = row;
|
||||
deleteReq.id = row.id;
|
||||
deleteReq.deleteFile = false;
|
||||
deleteRef.value?.acceptParams({
|
||||
title: i18n.global.t('commons.button.delete'),
|
||||
msg: i18n.global.t('xpack.vm.deleteIsoConfirm'),
|
||||
names: [],
|
||||
api: async (params: VM.IsoDelete) => {
|
||||
if (deleteRow.value?.tempFileID) {
|
||||
if (deleteRow.value.tempFileType === 'upload') {
|
||||
await stopChunkUpload(deleteRow.value.tempFileID).catch(() => undefined);
|
||||
}
|
||||
if (deleteRow.value.tempFileType === 'download') {
|
||||
await stopWgetFile(deleteRow.value.tempFileID).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
return deleteVMIso(params);
|
||||
},
|
||||
params: deleteReq,
|
||||
successMsg: i18n.global.t('commons.msg.operationSuccess'),
|
||||
});
|
||||
};
|
||||
|
||||
const resetDelete = () => {
|
||||
deleteRow.value = undefined;
|
||||
deleteReq.id = 0;
|
||||
deleteReq.deleteFile = false;
|
||||
};
|
||||
|
||||
const handleDeleteSuccess = () => {
|
||||
resetDelete();
|
||||
search();
|
||||
};
|
||||
|
||||
watch(() => [props.vmEnvironmentLoaded, props.vmUnavailable], search, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.iso-card-content {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.iso-delete-dialog__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.iso-delete-dialog__helper {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.iso-card-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(360px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.iso-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 160px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-card-border-color, var(--el-border-color-lighter));
|
||||
border-radius: 8px;
|
||||
background: var(--el-card-bg-color, var(--el-fill-color-blank));
|
||||
}
|
||||
|
||||
.iso-card__header,
|
||||
.iso-card__title,
|
||||
.iso-card__detail-item,
|
||||
.iso-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iso-card__error {
|
||||
overflow: hidden;
|
||||
color: var(--el-color-danger);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iso-card__header {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iso-card__title {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.iso-card__name {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iso-card__sub {
|
||||
overflow: hidden;
|
||||
min-height: 22px;
|
||||
margin-top: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iso-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.iso-card__meta-item {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.iso-card__meta-label,
|
||||
.iso-card__detail-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.iso-card__meta-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.iso-card__meta-value {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iso-card__detail {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.iso-card__detail--last {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.iso-card__detail-item {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.iso-card__detail-item strong {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.iso-card__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.iso-card__actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.iso-card-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.iso-exist-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.iso-card-list {
|
||||
grid-template-columns: repeat(2, minmax(360px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.iso-card-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.iso-card-pagination {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,102 @@
|
||||
<template>
|
||||
<DialogPro v-model="visible" :title="$t('commons.button.delete') + ' - ' + (source?.name || '')" size="small">
|
||||
<el-form label-position="top" :model="form">
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.forceDelete">
|
||||
{{ $t('xpack.vm.forceDelete') }}
|
||||
</el-checkbox>
|
||||
<span class="input-help">{{ $t('xpack.vm.forceDeleteHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<div class="confirm-delete-input">
|
||||
<div class="confirm-delete-input__tip">
|
||||
<span>{{ $t('database.delete') }}</span>
|
||||
<span class="confirm-delete-input__name">{{ source?.name || '' }}</span>
|
||||
<span>{{ $t('xpack.vm.deleteNameHelper') }}</span>
|
||||
</div>
|
||||
<el-input v-model="form.confirmName" :placeholder="source?.name || ''" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-permission
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="form.confirmName !== source?.name"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DialogPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import i18n from '@/lang';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { deleteVM } from '@/api/modules/vm';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'task-success', taskID: string): void;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const source = ref<VM.Item>();
|
||||
const form = reactive({
|
||||
forceDelete: false,
|
||||
confirmName: '',
|
||||
});
|
||||
|
||||
const open = (row: VM.Item) => {
|
||||
source.value = row;
|
||||
form.forceDelete = false;
|
||||
form.confirmName = '';
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!source.value || form.confirmName !== source.value.name) {
|
||||
return;
|
||||
}
|
||||
const taskID = newUUID();
|
||||
loading.value = true;
|
||||
await deleteVM({
|
||||
taskID,
|
||||
id: source.value.id,
|
||||
force: form.forceDelete,
|
||||
})
|
||||
.then((res) => {
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
emit('task-success', res.data?.taskID || taskID);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.confirm-delete-input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.confirm-delete-input__tip {
|
||||
font-size: 12px;
|
||||
line-height: 20px;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.confirm-delete-input__name {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,109 @@
|
||||
<template>
|
||||
<DrawerPro v-model="open" :header="vmItem?.name || $t('xpack.vm.title')" size="large">
|
||||
<div v-if="vmItem" v-loading="loading" class="vm-detail">
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item :label="$t('commons.table.name')">
|
||||
{{ vmItem.name }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="UUID">{{ vmItem.uuid || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('commons.table.status')">
|
||||
<Status :status="vmItem.status === 'shut off' ? 'stopped' : vmItem.status"></Status>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.cpu')">{{ vmItem.cpu }}</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.memory')">
|
||||
{{ computeSizeFromMB(vmItem.memory) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.diskBus')">
|
||||
{{ vmItem.diskBus?.toUpperCase() || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.network')">
|
||||
{{ vmItem.network || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.bootOrder')">
|
||||
{{ vmItem.bootOrder || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.isoPath')">
|
||||
<div v-for="path in vmItem.isoPaths || []" :key="path">
|
||||
{{ path }}
|
||||
</div>
|
||||
<span v-if="!vmItem.isoPaths?.length">-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('xpack.vm.console')">
|
||||
<span v-if="vmItem.graphics">
|
||||
{{ vmItem.graphics.type?.toUpperCase() }} {{ vmItem.graphics.listen || '-' }}:{{
|
||||
vmItem.graphics.port || '-'
|
||||
}}
|
||||
</span>
|
||||
<span v-else>-</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item :label="$t('commons.table.description')">
|
||||
{{ vmItem.description || '-' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="section-title">{{ $t('xpack.vm.disk') }}</div>
|
||||
<ComplexTable :data="vmItem.disks || []">
|
||||
<el-table-column prop="device" :label="$t('xpack.vm.device')" width="110" />
|
||||
<el-table-column prop="target" label="Target" width="110" />
|
||||
<el-table-column prop="bus" label="Bus" width="100" />
|
||||
<el-table-column prop="source" :label="$t('xpack.vm.path')" min-width="260" show-overflow-tooltip />
|
||||
</ComplexTable>
|
||||
|
||||
<div class="section-title">{{ $t('xpack.vm.interface') }}</div>
|
||||
<ComplexTable :data="vmItem.interfaces || []">
|
||||
<el-table-column prop="type" :label="$t('commons.table.type')" width="110" />
|
||||
<el-table-column prop="source" :label="$t('xpack.vm.network')" min-width="160" />
|
||||
<el-table-column prop="model" label="Model" width="120" />
|
||||
<el-table-column prop="mac" label="MAC" min-width="160" />
|
||||
<el-table-column prop="ip" label="IP" min-width="140">
|
||||
<template #default="{ row }">{{ row.ip || '-' }}</template>
|
||||
</el-table-column>
|
||||
</ComplexTable>
|
||||
</div>
|
||||
</DrawerPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { getVM } from '@/api/modules/vm';
|
||||
import { computeSizeFromMB } from '@/utils/size';
|
||||
import { ref } from 'vue';
|
||||
|
||||
const open = ref(false);
|
||||
const loading = ref(false);
|
||||
const vmItem = ref<any>(null);
|
||||
|
||||
const acceptParams = (name: string): void => {
|
||||
open.value = true;
|
||||
loadDetail(name);
|
||||
};
|
||||
|
||||
const loadDetail = async (name: string) => {
|
||||
loading.value = true;
|
||||
await getVM(name)
|
||||
.then((res) => {
|
||||
vmItem.value = res.data;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-top: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,156 @@
|
||||
<template>
|
||||
<DrawerPro
|
||||
v-model="open"
|
||||
:header="$t('xpack.vm.disks')"
|
||||
:resource="name"
|
||||
size="70%"
|
||||
full-screen
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #content>
|
||||
<p class="input-help mb-4">{{ $t('xpack.vm.diskResizeLimit') }}</p>
|
||||
<div class="mb-4">
|
||||
<el-button :disabled="loading" @click="search">{{ $t('commons.button.refresh') }}</el-button>
|
||||
</div>
|
||||
<el-table v-loading="loading" :data="items">
|
||||
<el-table-column prop="target" :label="$t('xpack.vm.diskTarget')" width="85" />
|
||||
<el-table-column :label="$t('xpack.vm.diskSize')" width="110">
|
||||
<template #default="{ row }">{{ row.capacity > 0 ? computeSize(row.capacity) : '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="bus" :label="$t('xpack.vm.diskBus')" width="100" />
|
||||
<el-table-column prop="source" :label="$t('xpack.vm.diskPath')" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column :label="$t('commons.table.status')" min-width="250">
|
||||
<template #default="{ row }">
|
||||
{{ row.resizeBlocked ? $t(`xpack.vm.diskResizeReason.${row.resizeBlocked}`) : '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.operate')" width="90" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-permission
|
||||
link
|
||||
type="primary"
|
||||
:disabled="loading || !!row.resizeBlocked || !row.capacity"
|
||||
@click="openResize(row)"
|
||||
>
|
||||
{{ $t('xpack.vm.resizeDisk') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
|
||||
<DialogPro v-model="resizeVisible" :title="$t('xpack.vm.resizeDisk')" size="small" :close-on-click-modal="false">
|
||||
<el-form v-if="selected" ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item :label="$t('xpack.vm.diskTarget')">{{ selected.target }}</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.diskSize')">{{ computeSize(selected.capacity) }}</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.diskCapacityAfterResize')" prop="sizeGiB">
|
||||
<el-input-number
|
||||
v-model="form.sizeGiB"
|
||||
:min="minSize"
|
||||
:max="8388607"
|
||||
:precision="0"
|
||||
:disabled="submitting"
|
||||
/>
|
||||
<span class="ml-2">GiB</span>
|
||||
</el-form-item>
|
||||
<p class="input-help">{{ $t('xpack.vm.diskResizeHelper') }}</p>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitting" @click="resizeVisible = false">
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button v-permission type="primary" :loading="submitting" @click="submit">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DialogPro>
|
||||
<TaskLog ref="taskLogRef" width="70%" @close="refreshAfterTask" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import type { FormInstance } from 'element-plus';
|
||||
import DrawerPro from '@/components/drawer-pro/index.vue';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { computeSizeForDocker as computeSize } from '@/utils/size';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { getVM, resizeVMDisk } from '@/api/modules/vm';
|
||||
|
||||
const emit = defineEmits(['refresh']);
|
||||
const open = ref(false);
|
||||
const name = ref('');
|
||||
const loading = ref(false);
|
||||
const items = ref<VM.Disk[]>([]);
|
||||
const selected = ref<VM.Disk>();
|
||||
const resizeVisible = ref(false);
|
||||
const submitting = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const taskLogRef = ref();
|
||||
const form = reactive({ sizeGiB: 1 });
|
||||
const rules = { sizeGiB: [Rules.requiredInput, Rules.integerNumber] };
|
||||
const minSize = computed(() => Math.floor((selected.value?.capacity || 0) / 1024 ** 3) + 1);
|
||||
|
||||
const search = async () => {
|
||||
if (!open.value || !name.value) return;
|
||||
const requestedName = name.value;
|
||||
loading.value = true;
|
||||
items.value = [];
|
||||
try {
|
||||
const res = await getVM(requestedName, true);
|
||||
if (open.value && name.value === requestedName) {
|
||||
items.value = (res.data.disks || []).filter((disk) => disk.device === 'disk');
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const acceptParams = (row: VM.Item) => {
|
||||
name.value = row.name;
|
||||
open.value = true;
|
||||
search();
|
||||
};
|
||||
|
||||
const openResize = (disk: VM.Disk) => {
|
||||
selected.value = disk;
|
||||
form.sizeGiB = Math.floor(disk.capacity / 1024 ** 3) + 1;
|
||||
resizeVisible.value = true;
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!selected.value || submitting.value) return;
|
||||
await formRef.value?.validate();
|
||||
submitting.value = true;
|
||||
const taskID = newUUID();
|
||||
try {
|
||||
const res = await resizeVMDisk({
|
||||
name: name.value,
|
||||
target: selected.value.target,
|
||||
sizeGiB: form.sizeGiB,
|
||||
taskID,
|
||||
});
|
||||
resizeVisible.value = false;
|
||||
taskLogRef.value?.openWithTaskID(res.data.taskID || taskID, true);
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAfterTask = () => {
|
||||
search();
|
||||
emit('refresh');
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
items.value = [];
|
||||
resizeVisible.value = false;
|
||||
selected.value = undefined;
|
||||
};
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
@@ -0,0 +1,249 @@
|
||||
<template>
|
||||
<DrawerPro v-model="visible" :header="$t('xpack.vm.healthCheck')" :resource="vmName" size="large">
|
||||
<div v-loading="loading" class="vm-health-check">
|
||||
<template v-if="result">
|
||||
<el-alert :title="summaryTitle" :type="summaryType" :closable="false" show-icon />
|
||||
|
||||
<div class="vm-health-summary">
|
||||
<el-tag type="success" effect="plain">
|
||||
{{ $t('xpack.vm.healthCheckResult.pass') }} {{ result.pass }}
|
||||
</el-tag>
|
||||
<el-tag type="warning" effect="plain">
|
||||
{{ $t('xpack.vm.healthCheckResult.warn') }} {{ result.warn }}
|
||||
</el-tag>
|
||||
<el-tag type="danger" effect="plain">
|
||||
{{ $t('xpack.vm.healthCheckResult.fail') }} {{ result.fail }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="vm-health-items">
|
||||
<div v-for="(item, index) in result.items" :key="`${item.check}-${index}`" class="vm-health-item">
|
||||
<el-tag :type="levelType(item.level)" effect="plain" class="vm-health-level">
|
||||
{{ levelLabel(item.level) }}
|
||||
</el-tag>
|
||||
<div class="vm-health-content">
|
||||
<div class="vm-health-message">
|
||||
<strong>{{ checkTitle(item) }}</strong>
|
||||
<span>{{ itemMessage(item) }}</span>
|
||||
</div>
|
||||
<div v-if="item.fixCommand" class="vm-health-extra">
|
||||
<span class="vm-health-extra__label">
|
||||
{{ $t('xpack.vm.healthCheckResult.fixCommand') }}
|
||||
</span>
|
||||
<div class="vm-health-command">
|
||||
<code>{{ item.fixCommand }}</code>
|
||||
<CopyButton :content="item.fixCommand" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="item.detail" class="vm-health-extra">
|
||||
<span class="vm-health-extra__label">
|
||||
{{ $t('xpack.vm.healthCheckResult.technicalDetail') }}
|
||||
</span>
|
||||
<pre class="vm-health-detail">{{ item.detail }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</DrawerPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
||||
import i18n from '@/lang';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { healthCheckVM } from '@/api/modules/vm';
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const vmName = ref('');
|
||||
const result = ref<VM.HealthCheckResult>();
|
||||
let requestID = 0;
|
||||
let abortController: AbortController | undefined;
|
||||
|
||||
const summaryType = computed<'success' | 'warning' | 'error'>(() => {
|
||||
if (result.value?.fail) {
|
||||
return 'error';
|
||||
}
|
||||
if (result.value?.warn) {
|
||||
return 'warning';
|
||||
}
|
||||
return 'success';
|
||||
});
|
||||
|
||||
const summaryTitle = computed(() => {
|
||||
if (result.value?.fail) {
|
||||
return i18n.global.t('commons.status.failed');
|
||||
}
|
||||
if (result.value?.warn) {
|
||||
return i18n.global.t('commons.status.unhealthy');
|
||||
}
|
||||
return i18n.global.t('commons.status.healthy');
|
||||
});
|
||||
|
||||
const levelLabel = (level: VM.HealthCheckItem['level']) => {
|
||||
return i18n.global.t(`xpack.vm.healthCheckResult.${level.toLowerCase()}`);
|
||||
};
|
||||
|
||||
const checkTitle = (item: VM.HealthCheckItem) => {
|
||||
const key = `xpack.vm.healthCheckResult.check.${item.check}`;
|
||||
const title = i18n.global.t(key);
|
||||
return item.params?.subject ? `${title} · ${item.params.subject}` : title;
|
||||
};
|
||||
|
||||
const itemMessage = (item: VM.HealthCheckItem) => {
|
||||
if (item.level === 'PASS' || !item.reason) {
|
||||
return i18n.global.t('xpack.vm.healthCheckResult.normal');
|
||||
}
|
||||
const key = `xpack.vm.healthCheckResult.reason.${item.reason}`;
|
||||
return i18n.global.t(key, item.params || {});
|
||||
};
|
||||
|
||||
const levelType = (level: VM.HealthCheckItem['level']): 'success' | 'warning' | 'danger' | 'info' => {
|
||||
switch (level) {
|
||||
case 'PASS':
|
||||
return 'success';
|
||||
case 'WARN':
|
||||
return 'warning';
|
||||
case 'FAIL':
|
||||
return 'danger';
|
||||
default:
|
||||
return 'info';
|
||||
}
|
||||
};
|
||||
|
||||
const open = async (name: string) => {
|
||||
abortController?.abort();
|
||||
const currentRequestID = ++requestID;
|
||||
const controller = new AbortController();
|
||||
abortController = controller;
|
||||
vmName.value = name;
|
||||
result.value = undefined;
|
||||
visible.value = true;
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await healthCheckVM(name, controller.signal);
|
||||
if (currentRequestID !== requestID) {
|
||||
return;
|
||||
}
|
||||
result.value = res.data;
|
||||
} catch {
|
||||
if (currentRequestID === requestID) {
|
||||
result.value = undefined;
|
||||
}
|
||||
} finally {
|
||||
if (currentRequestID === requestID) {
|
||||
loading.value = false;
|
||||
abortController = undefined;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
watch(visible, (value) => {
|
||||
if (!value) {
|
||||
requestID++;
|
||||
abortController?.abort();
|
||||
abortController = undefined;
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
requestID++;
|
||||
abortController?.abort();
|
||||
});
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-health-check {
|
||||
min-height: 240px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.vm-health-summary {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.vm-health-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.vm-health-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background-color: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.vm-health-level {
|
||||
align-self: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vm-health-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vm-health-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.vm-health-message span {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.vm-health-extra {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.vm-health-extra__label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-health-command {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.vm-health-command code {
|
||||
flex: 1;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.vm-health-detail {
|
||||
max-height: 240px;
|
||||
margin: 4px 0 0;
|
||||
padding: 10px 12px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
background-color: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-family: var(--el-font-family);
|
||||
font-size: 11px;
|
||||
line-height: 17px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
||||
<template>
|
||||
<DrawerPro v-model="visible" :header="$t('xpack.vm.monitor')" :resource="title" size="large" @close="close">
|
||||
<template #content>
|
||||
<div v-loading="loading" class="vm-monitor-drawer">
|
||||
<div class="vm-monitor-summary">
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.cpuUsage') }}</span>
|
||||
<strong>{{ latestPoint ? `${latestPoint.cpu}%` : '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.memoryUsage') }}</span>
|
||||
<strong>{{ latestPoint ? `${latestPoint.memory}%` : '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.diskReadWrite') }}</span>
|
||||
<strong>
|
||||
{{
|
||||
latestPoint
|
||||
? `${formatRate(latestPoint.diskRead)} / ${formatRate(latestPoint.diskWrite)}`
|
||||
: '-'
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.networkRxTx') }}</span>
|
||||
<strong>
|
||||
{{
|
||||
latestPoint
|
||||
? `${formatRate(latestPoint.netRx)} / ${formatRate(latestPoint.netTx)}`
|
||||
: '-'
|
||||
}}
|
||||
</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-if="!points.length && !loading" />
|
||||
<div v-else class="vm-monitor-charts">
|
||||
<VCharts type="line" height="220px" :option="cpuOption" />
|
||||
<VCharts type="line" height="220px" :option="memoryOption" />
|
||||
<VCharts type="line" height="220px" :option="diskOption" />
|
||||
<VCharts type="line" height="220px" :option="networkOption" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref } from 'vue';
|
||||
import DrawerPro from '@/components/drawer-pro/index.vue';
|
||||
import VCharts from '@/components/v-charts/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError } from '@/utils/message';
|
||||
import { getErrorMessage } from '@/utils/misc';
|
||||
import { computeSize } from '@/utils/size.js';
|
||||
import { getVMStats } from '@/api/modules/vm';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
|
||||
const MONITOR_INTERVAL = 5000;
|
||||
const MONITOR_MAX_POINTS = 60;
|
||||
|
||||
type VMMonitorPoint = {
|
||||
time: string;
|
||||
timestamp: number;
|
||||
cpu: number;
|
||||
memory: number;
|
||||
diskRead: number;
|
||||
diskWrite: number;
|
||||
netRx: number;
|
||||
netTx: number;
|
||||
};
|
||||
|
||||
const visible = ref(false);
|
||||
const title = ref('');
|
||||
const loading = ref(false);
|
||||
const source = ref<VM.Item>();
|
||||
const previous = ref<VM.Stats>();
|
||||
const points = ref<VMMonitorPoint[]>([]);
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const latestPoint = computed(() => points.value[points.value.length - 1]);
|
||||
|
||||
const cpuOption = computed(() =>
|
||||
buildMonitorOption(i18n.global.t('xpack.vm.cpuUsage'), [
|
||||
{ name: i18n.global.t('xpack.vm.cpuUsage'), data: points.value.map((item) => item.cpu) },
|
||||
]),
|
||||
);
|
||||
|
||||
const memoryOption = computed(() =>
|
||||
buildMonitorOption(i18n.global.t('xpack.vm.memoryUsage'), [
|
||||
{ name: i18n.global.t('xpack.vm.memoryUsage'), data: points.value.map((item) => item.memory) },
|
||||
]),
|
||||
);
|
||||
|
||||
const diskOption = computed(() =>
|
||||
buildMonitorOption(
|
||||
i18n.global.t('xpack.vm.diskIO'),
|
||||
[
|
||||
{ name: i18n.global.t('monitor.read'), data: points.value.map((item) => item.diskRead) },
|
||||
{ name: i18n.global.t('monitor.write'), data: points.value.map((item) => item.diskWrite) },
|
||||
],
|
||||
'KB/s',
|
||||
),
|
||||
);
|
||||
|
||||
const networkOption = computed(() =>
|
||||
buildMonitorOption(
|
||||
i18n.global.t('xpack.vm.networkIO'),
|
||||
[
|
||||
{ name: i18n.global.t('xpack.vm.rx'), data: points.value.map((item) => item.netRx) },
|
||||
{ name: i18n.global.t('xpack.vm.tx'), data: points.value.map((item) => item.netTx) },
|
||||
],
|
||||
'KB/s',
|
||||
),
|
||||
);
|
||||
|
||||
const open = async (row: VM.Item) => {
|
||||
if (row.status !== 'running') {
|
||||
MsgError(i18n.global.t('xpack.vm.monitorNotReady'));
|
||||
return;
|
||||
}
|
||||
closeTimer();
|
||||
source.value = row;
|
||||
title.value = row.name;
|
||||
previous.value = undefined;
|
||||
points.value = [];
|
||||
visible.value = true;
|
||||
const loaded = await loadStats();
|
||||
if (loaded && visible.value) {
|
||||
timer = setInterval(loadStats, MONITOR_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
closeTimer();
|
||||
visible.value = false;
|
||||
title.value = '';
|
||||
source.value = undefined;
|
||||
previous.value = undefined;
|
||||
points.value = [];
|
||||
};
|
||||
|
||||
const closeTimer = () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!source.value || !visible.value || loading.value) {
|
||||
return false;
|
||||
}
|
||||
loading.value = !points.value.length;
|
||||
try {
|
||||
const res = await getVMStats(source.value.name);
|
||||
const current = res.data;
|
||||
if (current.status !== 'running') {
|
||||
closeTimer();
|
||||
MsgError(i18n.global.t('xpack.vm.monitorNotReady'));
|
||||
return false;
|
||||
}
|
||||
const point = buildMonitorPoint(current, previous.value, source.value);
|
||||
previous.value = current;
|
||||
points.value = [...points.value, point].slice(-MONITOR_MAX_POINTS);
|
||||
return true;
|
||||
} catch (error) {
|
||||
closeTimer();
|
||||
MsgError(loadFailedMessage(error));
|
||||
return false;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadFailedMessage = (error: unknown) => {
|
||||
const responseError = error as Record<string, any>;
|
||||
const reason =
|
||||
responseError?.response?.data?.message ||
|
||||
responseError?.response?.data?.msg ||
|
||||
responseError?.data?.message ||
|
||||
responseError?.data?.msg ||
|
||||
getErrorMessage(error);
|
||||
if (!reason || reason === '{}' || reason === 'undefined' || reason === 'null') {
|
||||
return i18n.global.t('xpack.vm.monitorLoadFailed');
|
||||
}
|
||||
return i18n.global.t('xpack.vm.monitorLoadFailedWithReason', [reason]);
|
||||
};
|
||||
|
||||
const buildMonitorPoint = (current: VM.Stats, last: VM.Stats | undefined, row: VM.Item): VMMonitorPoint => {
|
||||
const intervalSeconds = last ? Math.max((current.timestamp - last.timestamp) / 1000, 1) : 1;
|
||||
const cpuBase = intervalSeconds * 1000 * 1000 * 1000 * Math.max(current.vcpu || row.cpu || 1, 1);
|
||||
const memoryTotal = current.memory.availableKiB || current.memory.actualKiB || row.memory * 1024;
|
||||
const memoryUsed =
|
||||
memoryTotal && current.memory.unusedKiB
|
||||
? Math.max(memoryTotal - current.memory.unusedKiB, 0)
|
||||
: current.memory.rssKiB || 0;
|
||||
return {
|
||||
time: formatMonitorTime(current.timestamp),
|
||||
timestamp: current.timestamp,
|
||||
cpu: last ? toFixedNumber(clamp(((current.cpuTime - last.cpuTime) / cpuBase) * 100, 0, 100)) : 0,
|
||||
memory: memoryTotal ? toFixedNumber(clamp((memoryUsed / memoryTotal) * 100, 0, 100)) : 0,
|
||||
diskRead: last ? bytesPerSecondToKB(current.disk.readBytes, last.disk.readBytes, intervalSeconds) : 0,
|
||||
diskWrite: last ? bytesPerSecondToKB(current.disk.writeBytes, last.disk.writeBytes, intervalSeconds) : 0,
|
||||
netRx: last ? bytesPerSecondToKB(current.network.rxBytes, last.network.rxBytes, intervalSeconds) : 0,
|
||||
netTx: last ? bytesPerSecondToKB(current.network.txBytes, last.network.txBytes, intervalSeconds) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const buildMonitorOption = (chartTitle: string, yData: Array<{ name: string; data: number[] }>, formatStr = '%') => ({
|
||||
title: chartTitle,
|
||||
xData: points.value.map((item) => item.time),
|
||||
yData,
|
||||
formatStr,
|
||||
grid: { left: '6%', right: '5%', bottom: '16%', top: '16%' },
|
||||
});
|
||||
|
||||
const formatMonitorTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp);
|
||||
return [date.getHours(), date.getMinutes(), date.getSeconds()].map((item) => `${item}`.padStart(2, '0')).join(':');
|
||||
};
|
||||
|
||||
const bytesPerSecondToKB = (current: number, last: number, intervalSeconds: number) => {
|
||||
return toFixedNumber(Math.max(current - last, 0) / intervalSeconds / 1024);
|
||||
};
|
||||
|
||||
const toFixedNumber = (value: number) => Number((Number.isFinite(value) ? value : 0).toFixed(2));
|
||||
const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max);
|
||||
const formatRate = (value: number) => computeSize(value * 1024) + '/s';
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
closeTimer();
|
||||
});
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-monitor-drawer {
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.vm-monitor-summary {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.vm-monitor-summary > div {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.vm-monitor-summary span {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-monitor-summary strong {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-monitor-charts {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.vm-monitor-summary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,698 @@
|
||||
<template>
|
||||
<DrawerPro v-model="formVisible" :header="formTitle" size="large" :auto-close="!submitLoading" @close="resetForm">
|
||||
<el-form ref="formRef" v-loading="optionsLoading" label-position="top" :model="form" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model.trim="form.name" :disabled="formMode === 'edit'" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="formMode === 'create'">
|
||||
<el-checkbox v-model="createFromTemplate" @change="changeCreateFromTemplate">
|
||||
{{ $t('xpack.vm.createFromTemplate') }}
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item
|
||||
v-if="formMode === 'create' && createFromTemplate"
|
||||
:label="$t('xpack.vm.template')"
|
||||
prop="templateID"
|
||||
>
|
||||
<el-select v-model="form.templateID" class="full-width" filterable @change="changeTemplate">
|
||||
<el-option
|
||||
v-for="item in availableTemplateOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${computeSizeFromMB(item.memory)}, ${formatDiskSize(item.diskSize)})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="!createFromTemplate" :label="$t('xpack.vm.iso')" prop="isoPaths">
|
||||
<el-select
|
||||
v-model="form.isoPaths"
|
||||
multiple
|
||||
class="full-width"
|
||||
filterable
|
||||
clearable
|
||||
default-first-option
|
||||
@change="changeISO"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in isoOptions"
|
||||
:key="item.id"
|
||||
:label="item.name + ' (' + item.path + ')'"
|
||||
:value="item.path"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div v-if="!createFromTemplate" class="resource-create-helper">
|
||||
<span>{{ $t('xpack.vm.isoHelper') }}</span>
|
||||
<el-button link type="primary" @click="isoOperateRef?.acceptParams()">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div
|
||||
v-if="formMode === 'create' && !createFromTemplate && form.osType === 'windows'"
|
||||
class="resource-create-helper"
|
||||
>
|
||||
<span>
|
||||
{{ $t('xpack.vm.windowsDriverHelper') }}
|
||||
<span v-if="form.diskBus === 'virtio'">{{ $t('xpack.vm.windowsDiskDriverHelper') }}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<template v-if="formMode === 'create'">
|
||||
<el-form-item v-if="!createFromTemplate" :label="$t('xpack.vm.osType')" prop="osType">
|
||||
<el-select v-model="form.osType" class="full-width" @change="changeOSType">
|
||||
<el-option label="Windows" value="windows" />
|
||||
<el-option label="Linux" value="linux" />
|
||||
<el-option :label="$t('xpack.vm.osOther')" value="other" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.diskBus')" prop="diskBus">
|
||||
<el-select v-model="form.diskBus" class="full-width" :disabled="createFromTemplate">
|
||||
<el-option label="SATA (AHCI)" value="sata" />
|
||||
<el-option label="VirtIO" value="virtio" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</template>
|
||||
|
||||
<el-form-item :label="$t('xpack.vm.network')" prop="network">
|
||||
<el-select v-model="form.network" class="full-width" filterable clearable default-first-option>
|
||||
<el-option
|
||||
v-for="item in networkOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name} (${item.dhcpStart} - ${item.dhcpEnd})`"
|
||||
:value="item.name"
|
||||
:disabled="isVMResourceUnavailable(item.status)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div class="resource-create-helper">
|
||||
<span>{{ $t('xpack.vm.networkHelper') }}</span>
|
||||
<el-button link type="primary" @click="networkOperateRef?.acceptParams()">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item v-if="formMode === 'create'" :label="$t('xpack.vm.storagePool')" prop="storagePool">
|
||||
<el-select v-model="form.storagePool" class="full-width" filterable clearable default-first-option>
|
||||
<el-option
|
||||
v-for="item in storagePoolOptions"
|
||||
:key="item.id"
|
||||
:label="item.path ? `${item.name} (${item.path})` : item.name"
|
||||
:value="item.name"
|
||||
:disabled="isVMResourceUnavailable(item.status)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<div v-if="formMode === 'create'" class="resource-create-helper">
|
||||
<span>{{ $t('xpack.vm.storagePoolHelper') }}</span>
|
||||
<el-button link type="primary" @click="storageOperateRef?.acceptParams()">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-form-item v-else :label="$t('xpack.vm.bootOrder')" prop="bootOrder">
|
||||
<el-select v-model="form.bootOrder" class="full-width">
|
||||
<el-option :label="$t('xpack.vm.bootDiskFirst')" value="hd,cdrom" />
|
||||
<el-option
|
||||
:label="$t('xpack.vm.bootIsoFirst')"
|
||||
value="cdrom,hd"
|
||||
:disabled="!form.isoPaths.length"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item :label="$t('xpack.vm.cpu')" prop="cpu">
|
||||
<el-input v-model.number="form.cpu" class="p-w-300" :min="1" :max="maxCpu || 256">
|
||||
<template #append>
|
||||
<div style="width: 40px">{{ $t('commons.units.core') }}</div>
|
||||
</template>
|
||||
</el-input>
|
||||
<span v-if="cpuLimitText" class="input-help" :class="{ 'is-warning': cpuLimitExceeded }">
|
||||
{{ cpuLimitText }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.memory')" prop="memory">
|
||||
<el-input
|
||||
v-model.number="form.memory"
|
||||
class="p-w-300"
|
||||
:min="minMemorySize"
|
||||
:max="maxMemorySize || undefined"
|
||||
>
|
||||
<template #append>
|
||||
<el-select v-model="memoryUnit" class="p-w-80" @change="changeMemoryUnit">
|
||||
<el-option label="MiB" value="MiB" />
|
||||
<el-option label="GiB" value="GiB" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
<span v-if="memoryLimitText" class="input-help" :class="{ 'is-warning': memoryLimitExceeded }">
|
||||
{{ memoryLimitText }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
|
||||
<template v-if="formMode === 'create'">
|
||||
<el-form-item :label="$t('xpack.vm.diskSize')" prop="diskSize">
|
||||
<el-input
|
||||
v-model.number="form.diskSize"
|
||||
class="p-w-300"
|
||||
:min="minDiskSize"
|
||||
:max="maxDiskSize || undefined"
|
||||
>
|
||||
<template #append>
|
||||
<el-select v-model="diskUnit" class="p-w-80" @change="changeDiskUnit">
|
||||
<el-option label="MiB" value="MiB" />
|
||||
<el-option label="GiB" value="GiB" />
|
||||
<el-option label="TiB" value="TiB" />
|
||||
</el-select>
|
||||
</template>
|
||||
</el-input>
|
||||
<span v-if="storageLimitText" class="input-help" :class="{ 'is-warning': diskLimitExceeded }">
|
||||
{{ storageLimitText }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item>
|
||||
<el-checkbox v-if="formMode === 'create'" v-model="form.startAfterCreate">
|
||||
{{ $t('xpack.vm.startAfterCreate') }}
|
||||
</el-checkbox>
|
||||
<el-checkbox v-model="form.autoStart">
|
||||
{{ $t('xpack.vm.autoStart') }}
|
||||
</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="submitLoading" @click="formVisible = false">
|
||||
{{ $t('commons.button.cancel') }}
|
||||
</el-button>
|
||||
<el-button
|
||||
v-permission
|
||||
type="primary"
|
||||
:disabled="submitLoading || optionsLoading || props.resourceLimitLoading || selectedResourceUnavailable"
|
||||
@click="submitForm"
|
||||
>
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<IsoOperate ref="isoOperateRef" @search="loadIsoOptions" />
|
||||
<NetworkOperate ref="networkOperateRef" @search="loadNetworkOptions" />
|
||||
<StorageOperate ref="storageOperateRef" @search="loadStorageOptions" />
|
||||
<div v-if="formVisible && submitLoading" v-loading="submitLoading" class="vm-drawer-loading-mask"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import i18n from '@/lang';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { isVMMemoryAllocated, isVMResourceUnavailable } from '@/utils/vm';
|
||||
import { createVM, listVMIso, listVMNetworks, listVMStorages, listVMTemplates, updateVM } from '@/api/modules/vm';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { computeSize, computeSizeFromMB, convertBinarySize, type BinarySizeUnit } from '@/utils/size';
|
||||
import IsoOperate from '@/views/vm/iso/create/index.vue';
|
||||
import NetworkOperate from '@/views/vm/network/create/index.vue';
|
||||
import StorageOperate from '@/views/vm/storage/create/index.vue';
|
||||
|
||||
type FormMode = 'create' | 'edit';
|
||||
type SizeUnit = Extract<BinarySizeUnit, 'MiB' | 'GiB' | 'TiB'>;
|
||||
|
||||
const BYTE_PER_GIB = 1024 * 1024 * 1024;
|
||||
const MIN_MEMORY_MIB = 128;
|
||||
const MIN_DISK_GIB = 1;
|
||||
|
||||
interface ResourceLimit {
|
||||
cpuTotal: number;
|
||||
memoryTotal: number;
|
||||
memoryAllocated: number;
|
||||
memoryReserved: number;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
resourceLimit?: ResourceLimit;
|
||||
resourceLimitLoading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits(['open-log']);
|
||||
|
||||
const submitLoading = ref(false);
|
||||
const optionsLoading = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const formVisible = ref(false);
|
||||
const formMode = ref<FormMode>('create');
|
||||
const createFromTemplate = ref(false);
|
||||
const memoryUnit = ref<SizeUnit>('GiB');
|
||||
const diskUnit = ref<SizeUnit>('GiB');
|
||||
const lastMemoryUnit = ref<SizeUnit>('GiB');
|
||||
const lastDiskUnit = ref<SizeUnit>('GiB');
|
||||
const originalMemoryMiB = ref(0);
|
||||
const originalBootOrder = ref('hd');
|
||||
|
||||
const networkOptions = ref<VM.NetworkItem[]>([]);
|
||||
const isoOptions = ref<VM.IsoItem[]>([]);
|
||||
const storagePoolOptions = ref<VM.StorageItem[]>([]);
|
||||
const templateOptions = ref<VM.TemplateItem[]>([]);
|
||||
const isoOperateRef = ref<InstanceType<typeof IsoOperate>>();
|
||||
const networkOperateRef = ref<InstanceType<typeof NetworkOperate>>();
|
||||
const storageOperateRef = ref<InstanceType<typeof StorageOperate>>();
|
||||
|
||||
const form = reactive<VM.Create>({
|
||||
name: '',
|
||||
cpu: 2,
|
||||
memory: 2,
|
||||
diskSize: 20,
|
||||
osType: 'linux',
|
||||
diskBus: 'virtio',
|
||||
templateID: undefined,
|
||||
diskPath: '',
|
||||
storagePool: 'default',
|
||||
isoPaths: [],
|
||||
imagePath: '',
|
||||
network: 'default',
|
||||
bootOrder: 'hd',
|
||||
startAfterCreate: true,
|
||||
autoStart: false,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const rules = computed<FormRules>(() => ({
|
||||
name: [Rules.requiredInput, Rules.vmName],
|
||||
cpu: [Rules.requiredInput, Rules.integerNumber],
|
||||
memory: [Rules.requiredInput, Rules.integerNumber],
|
||||
diskSize: [Rules.requiredInput, Rules.integerNumber],
|
||||
osType: [Rules.requiredSelect],
|
||||
diskBus: [Rules.requiredSelect],
|
||||
templateID: [Rules.requiredSelect],
|
||||
isoPaths:
|
||||
formMode.value === 'create' && !createFromTemplate.value ? [{ ...Rules.requiredSelect, type: 'array' }] : [],
|
||||
network: [Rules.requiredInput, Rules.vmNetwork],
|
||||
storagePool: [Rules.requiredSelect],
|
||||
}));
|
||||
|
||||
const formTitle = computed(() =>
|
||||
formMode.value === 'create' ? i18n.global.t('commons.button.create') : i18n.global.t('commons.button.edit'),
|
||||
);
|
||||
const selectedStoragePool = computed(() => storagePoolOptions.value.find((item) => item.name === form.storagePool));
|
||||
const selectedResourceUnavailable = computed(
|
||||
() =>
|
||||
isVMResourceUnavailable(networkOptions.value.find((item) => item.name === form.network)?.status) ||
|
||||
(formMode.value === 'create' && isVMResourceUnavailable(selectedStoragePool.value?.status)),
|
||||
);
|
||||
const selectedTemplate = computed(() => templateOptions.value.find((item) => item.id === form.templateID));
|
||||
const availableTemplateOptions = computed(() => templateOptions.value.filter((item) => item.isExist));
|
||||
const diskAvailableBytes = computed(() => selectedStoragePool.value?.available || 0);
|
||||
const maxCpu = computed(() => Math.floor(props.resourceLimit?.cpuTotal || 0));
|
||||
const cpuLimitExceeded = computed(() => maxCpu.value > 0 && Number(form.cpu || 0) > maxCpu.value);
|
||||
const requestMemoryBytes = computed(() => toMiB(form.memory, memoryUnit.value) * 1024 * 1024);
|
||||
const originalMemoryBytes = computed(() => originalMemoryMiB.value * 1024 * 1024);
|
||||
const memoryLimitBytes = computed(() =>
|
||||
Math.max((props.resourceLimit?.memoryTotal || 0) - (props.resourceLimit?.memoryReserved || 0), 0),
|
||||
);
|
||||
const memoryAvailableBytes = computed(() =>
|
||||
Math.max(memoryLimitBytes.value - (props.resourceLimit?.memoryAllocated || 0) + originalMemoryBytes.value, 0),
|
||||
);
|
||||
const memoryLimitExceeded = computed(
|
||||
() => memoryLimitBytes.value > 0 && requestMemoryBytes.value > memoryAvailableBytes.value,
|
||||
);
|
||||
const minMemorySize = computed(() => (memoryUnit.value === 'MiB' ? MIN_MEMORY_MIB : 1));
|
||||
const maxMemorySize = computed(() => {
|
||||
if (!memoryAvailableBytes.value) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(convertBinarySize(memoryAvailableBytes.value, 'B', memoryUnit.value, 6));
|
||||
});
|
||||
const templateMinDiskSizeGiB = computed(() => (createFromTemplate.value ? selectedTemplate.value?.diskSize || 0 : 0));
|
||||
const minDiskSize = computed(() => {
|
||||
const fallback = convertBinarySize(MIN_DISK_GIB, 'GiB', diskUnit.value, 6);
|
||||
if (!templateMinDiskSizeGiB.value) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.max(convertBinarySize(templateMinDiskSizeGiB.value, 'GiB', diskUnit.value, 6), fallback);
|
||||
});
|
||||
const maxDiskSize = computed(() => {
|
||||
if (formMode.value !== 'create' || !diskAvailableBytes.value) {
|
||||
return 0;
|
||||
}
|
||||
return Math.floor(convertBinarySize(diskAvailableBytes.value, 'B', diskUnit.value, 6));
|
||||
});
|
||||
const requestDiskBytes = computed(() => toGiB(form.diskSize, diskUnit.value) * BYTE_PER_GIB);
|
||||
const diskLimitExceeded = computed(
|
||||
() =>
|
||||
formMode.value === 'create' &&
|
||||
diskAvailableBytes.value > 0 &&
|
||||
requestDiskBytes.value > diskAvailableBytes.value,
|
||||
);
|
||||
const cpuLimitText = computed(() => {
|
||||
if (!maxCpu.value) {
|
||||
return '';
|
||||
}
|
||||
return i18n.global.t('xpack.vm.cpuLimitHelper', [maxCpu.value]);
|
||||
});
|
||||
const memoryLimitText = computed(() => {
|
||||
if (!memoryLimitBytes.value) {
|
||||
return '';
|
||||
}
|
||||
return i18n.global.t('xpack.vm.memoryLimitHelper', [
|
||||
computeSize(memoryAvailableBytes.value),
|
||||
computeSize(props.resourceLimit?.memoryReserved || 0),
|
||||
]);
|
||||
});
|
||||
const storageLimitText = computed(() => {
|
||||
if (formMode.value !== 'create' || !diskAvailableBytes.value) {
|
||||
return '';
|
||||
}
|
||||
return i18n.global.t('xpack.vm.storageLimitHelper', [computeSize(diskAvailableBytes.value)]);
|
||||
});
|
||||
|
||||
const loadResourceOptions = async (includeTemplates = false) => {
|
||||
optionsLoading.value = true;
|
||||
try {
|
||||
const requests = [
|
||||
listVMNetworks(),
|
||||
listVMStorages(),
|
||||
listVMIso(),
|
||||
...(includeTemplates ? [listVMTemplates()] : []),
|
||||
] as const;
|
||||
const [networkRes, storageRes, isoRes, templateRes] = await Promise.all(requests);
|
||||
networkOptions.value = networkRes.data || [];
|
||||
isoOptions.value = isoRes.data || [];
|
||||
storagePoolOptions.value = storageRes.data || [];
|
||||
templateOptions.value = templateRes?.data || [];
|
||||
} finally {
|
||||
optionsLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadIsoOptions = async () => {
|
||||
const res = await listVMIso();
|
||||
isoOptions.value = res.data || [];
|
||||
};
|
||||
|
||||
const loadNetworkOptions = async () => {
|
||||
const res = await listVMNetworks();
|
||||
networkOptions.value = res.data || [];
|
||||
};
|
||||
|
||||
const loadStorageOptions = async () => {
|
||||
const res = await listVMStorages();
|
||||
storagePoolOptions.value = res.data || [];
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
loadResourceOptions(true);
|
||||
resetForm();
|
||||
formMode.value = 'create';
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const openEdit = (row: VM.Item) => {
|
||||
loadResourceOptions();
|
||||
resetForm();
|
||||
formMode.value = 'edit';
|
||||
form.name = row.name;
|
||||
form.cpu = row.cpu || 1;
|
||||
originalMemoryMiB.value = isVMMemoryAllocated(row.status) ? row.memory || 0 : 0;
|
||||
setMemoryFromMiB(row.memory || 2048);
|
||||
form.isoPaths = [...(row.isoPaths || [])];
|
||||
form.network = row.network || 'default';
|
||||
form.bootOrder = normalizeBootOrder(row.bootOrder, form.isoPaths);
|
||||
originalBootOrder.value = form.bootOrder;
|
||||
form.autoStart = row.autoStart || false;
|
||||
form.description = row.description || '';
|
||||
formVisible.value = true;
|
||||
};
|
||||
|
||||
const submitForm = async () => {
|
||||
if (!formRef.value) {
|
||||
return;
|
||||
}
|
||||
if (optionsLoading.value || props.resourceLimitLoading || selectedResourceUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
await formRef.value.validate();
|
||||
if (cpuLimitExceeded.value) {
|
||||
MsgError(i18n.global.t('xpack.vm.cpuLimitExceeded', [maxCpu.value]));
|
||||
return;
|
||||
}
|
||||
if (memoryLimitExceeded.value) {
|
||||
MsgError(i18n.global.t('xpack.vm.memoryLimitExceeded', [computeSize(memoryAvailableBytes.value)]));
|
||||
return;
|
||||
}
|
||||
if (toMiB(form.memory, memoryUnit.value) < MIN_MEMORY_MIB) {
|
||||
MsgError(`${i18n.global.t('xpack.vm.memory')} >= ${MIN_MEMORY_MIB} MiB`);
|
||||
return;
|
||||
}
|
||||
if (createFromTemplate.value && toGiB(form.diskSize, diskUnit.value) < templateMinDiskSizeGiB.value) {
|
||||
MsgError(`${i18n.global.t('xpack.vm.diskSize')} >= ${formatDiskSize(templateMinDiskSizeGiB.value)}`);
|
||||
return;
|
||||
}
|
||||
if (formMode.value === 'create') {
|
||||
const diskSizeGiB = toGiB(form.diskSize, diskUnit.value);
|
||||
if (diskSizeGiB < MIN_DISK_GIB) {
|
||||
MsgError(`${i18n.global.t('xpack.vm.diskSize')} >= ${formatDiskSize(MIN_DISK_GIB)}`);
|
||||
return;
|
||||
}
|
||||
if (diskLimitExceeded.value) {
|
||||
MsgError(i18n.global.t('xpack.vm.storageLimitExceeded', [computeSize(diskAvailableBytes.value)]));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (formMode.value === 'edit') {
|
||||
form.bootOrder = normalizeBootOrder(form.bootOrder, form.isoPaths);
|
||||
if (form.bootOrder !== originalBootOrder.value) {
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('xpack.vm.bootOrderConfirm', [formatBootOrder(form.bootOrder)]),
|
||||
i18n.global.t('xpack.vm.bootOrder'),
|
||||
);
|
||||
}
|
||||
}
|
||||
submitLoading.value = true;
|
||||
try {
|
||||
const taskID = newUUID();
|
||||
const memoryMiB = toMiB(form.memory, memoryUnit.value);
|
||||
const diskSizeGiB = toGiB(form.diskSize, diskUnit.value);
|
||||
const res =
|
||||
formMode.value === 'create'
|
||||
? await createVM({
|
||||
...form,
|
||||
taskID,
|
||||
memory: memoryMiB,
|
||||
diskSize: diskSizeGiB,
|
||||
diskPath: form.diskPath || '',
|
||||
templateID: createFromTemplate.value ? form.templateID : undefined,
|
||||
isoPaths: createFromTemplate.value ? [] : form.isoPaths,
|
||||
imagePath: '',
|
||||
bootOrder: createFromTemplate.value ? 'hd' : 'hd,cdrom',
|
||||
})
|
||||
: await updateVM({
|
||||
taskID,
|
||||
name: form.name,
|
||||
cpu: form.cpu,
|
||||
memory: toMiB(form.memory, memoryUnit.value),
|
||||
isoPaths: form.isoPaths,
|
||||
network: form.network,
|
||||
bootOrder: form.bootOrder,
|
||||
autoStart: form.autoStart,
|
||||
description: form.description,
|
||||
});
|
||||
formVisible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
emit('open-log', res.data?.taskID || taskID);
|
||||
} finally {
|
||||
submitLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.name = '';
|
||||
form.cpu = 2;
|
||||
form.memory = 2;
|
||||
originalMemoryMiB.value = 0;
|
||||
originalBootOrder.value = 'hd';
|
||||
form.diskSize = 20;
|
||||
form.osType = 'linux';
|
||||
form.diskBus = 'virtio';
|
||||
createFromTemplate.value = false;
|
||||
setUnit(memoryUnit, lastMemoryUnit, 'GiB');
|
||||
setUnit(diskUnit, lastDiskUnit, 'GiB');
|
||||
form.templateID = undefined;
|
||||
form.diskPath = '';
|
||||
form.storagePool = 'default';
|
||||
form.isoPaths = [];
|
||||
form.imagePath = '';
|
||||
form.network = 'default';
|
||||
form.bootOrder = 'hd';
|
||||
form.startAfterCreate = true;
|
||||
form.autoStart = false;
|
||||
form.description = '';
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
const changeCreateFromTemplate = () => {
|
||||
form.templateID = undefined;
|
||||
form.imagePath = '';
|
||||
if (!createFromTemplate.value) {
|
||||
changeOSType();
|
||||
return;
|
||||
}
|
||||
form.isoPaths = [];
|
||||
form.bootOrder = 'hd';
|
||||
const first = availableTemplateOptions.value[0];
|
||||
if (first) {
|
||||
form.templateID = first.id;
|
||||
applyTemplate(first);
|
||||
}
|
||||
};
|
||||
|
||||
const changeTemplate = () => {
|
||||
if (selectedTemplate.value) {
|
||||
applyTemplate(selectedTemplate.value);
|
||||
}
|
||||
};
|
||||
|
||||
const applyTemplate = (item: VM.TemplateItem) => {
|
||||
form.cpu = item.cpu || form.cpu;
|
||||
setMemoryFromMiB(item.memory || 2048);
|
||||
form.diskSize = item.diskSize || form.diskSize;
|
||||
form.diskBus = item.diskBus === 'sata' ? 'sata' : 'virtio';
|
||||
setUnit(diskUnit, lastDiskUnit, 'GiB');
|
||||
form.storagePool = item.storagePool || form.storagePool;
|
||||
form.network = item.network || form.network;
|
||||
form.bootOrder = createFromTemplate.value ? 'hd' : normalizeBootOrder(item.bootOrder, form.isoPaths);
|
||||
};
|
||||
|
||||
const changeOSType = () => {
|
||||
form.diskBus = form.osType === 'windows' ? 'sata' : 'virtio';
|
||||
};
|
||||
|
||||
const normalizeBootOrder = (bootOrder: string | undefined, isoPaths: string[]) => {
|
||||
return isoPaths.length && String(bootOrder || '').startsWith('cdrom') ? 'cdrom,hd' : 'hd,cdrom';
|
||||
};
|
||||
|
||||
const changeISO = () => {
|
||||
form.bootOrder = normalizeBootOrder(form.bootOrder, form.isoPaths);
|
||||
if (formMode.value !== 'create' || createFromTemplate.value) {
|
||||
return;
|
||||
}
|
||||
const iso = isoOptions.value.find((item) => item.path === form.isoPaths[0]);
|
||||
if (!iso) {
|
||||
return;
|
||||
}
|
||||
const type = iso.type.trim().toLowerCase();
|
||||
const linuxTypes = [
|
||||
'linux',
|
||||
'debian',
|
||||
'ubuntu',
|
||||
'centos',
|
||||
'rocky',
|
||||
'almalinux',
|
||||
'fedora',
|
||||
'opensuse',
|
||||
'rhel',
|
||||
'oraclelinux',
|
||||
'archlinux',
|
||||
];
|
||||
const osType = type === 'windows' ? 'windows' : linuxTypes.includes(type) ? 'linux' : 'other';
|
||||
if (form.osType !== osType) {
|
||||
form.osType = osType;
|
||||
changeOSType();
|
||||
}
|
||||
};
|
||||
|
||||
const formatBootOrder = (bootOrder: string) => {
|
||||
return bootOrder.startsWith('cdrom')
|
||||
? i18n.global.t('xpack.vm.bootIsoFirst')
|
||||
: i18n.global.t('xpack.vm.bootDiskFirst');
|
||||
};
|
||||
|
||||
const setUnit = (target: typeof memoryUnit, lastTarget: typeof lastMemoryUnit, unit: SizeUnit) => {
|
||||
target.value = unit;
|
||||
lastTarget.value = unit;
|
||||
};
|
||||
|
||||
const convertDisplaySize = (value: number, from: SizeUnit, to: SizeUnit) => {
|
||||
if (!value || from === to) {
|
||||
return value;
|
||||
}
|
||||
return convertBinarySize(value, from, to);
|
||||
};
|
||||
|
||||
const toMiB = (value: number, unit: SizeUnit) => {
|
||||
return Math.ceil(convertBinarySize(Number(value) || 0, unit, 'MiB', 6));
|
||||
};
|
||||
|
||||
const toGiB = (value: number, unit: SizeUnit) => {
|
||||
return Math.ceil(convertBinarySize(Number(value) || 0, unit, 'GiB', 6));
|
||||
};
|
||||
|
||||
const formatDiskSize = (size: number) => {
|
||||
if (!size) {
|
||||
return '-';
|
||||
}
|
||||
return `${size} GiB`;
|
||||
};
|
||||
|
||||
const normalizeSizeUnit = (unit: string | number | boolean | undefined): SizeUnit => {
|
||||
const target = String(unit);
|
||||
return ['MiB', 'GiB', 'TiB'].includes(target) ? (target as SizeUnit) : 'GiB';
|
||||
};
|
||||
|
||||
const changeMemoryUnit = (unit: string | number | boolean | undefined) => {
|
||||
const target = normalizeSizeUnit(unit);
|
||||
form.memory = convertDisplaySize(form.memory, lastMemoryUnit.value, target);
|
||||
lastMemoryUnit.value = target;
|
||||
};
|
||||
|
||||
const changeDiskUnit = (unit: string | number | boolean | undefined) => {
|
||||
const target = normalizeSizeUnit(unit);
|
||||
form.diskSize = convertDisplaySize(form.diskSize, lastDiskUnit.value, target);
|
||||
lastDiskUnit.value = target;
|
||||
};
|
||||
|
||||
const setMemoryFromMiB = (memoryMiB: number) => {
|
||||
if (memoryMiB % 1024 === 0) {
|
||||
setUnit(memoryUnit, lastMemoryUnit, 'GiB');
|
||||
form.memory = convertBinarySize(memoryMiB, 'MiB', 'GiB');
|
||||
return;
|
||||
}
|
||||
setUnit(memoryUnit, lastMemoryUnit, 'MiB');
|
||||
form.memory = memoryMiB;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
openCreate,
|
||||
openEdit,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.full-width {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-help.is-warning {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.resource-create-helper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: -14px 0 18px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-drawer-loading-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000;
|
||||
width: 50%;
|
||||
background: var(--el-mask-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,74 @@
|
||||
<template>
|
||||
<DialogPro v-model="visible" :title="$t('xpack.vm.rename') + ' - ' + (source?.name || '')" size="small">
|
||||
<el-form label-position="top" :model="form">
|
||||
<el-form-item :label="$t('xpack.vm.newName')">
|
||||
<el-input v-model.trim="form.newName" />
|
||||
<span class="input-help">{{ $t('xpack.vm.renameHelper') }}</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button v-permission type="primary" :loading="loading" @click="submit">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DialogPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { MsgError, MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { renameVM } from '@/api/modules/vm';
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'task-success', taskID: string): void;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const source = ref<VM.Item>();
|
||||
const form = reactive({
|
||||
newName: '',
|
||||
});
|
||||
|
||||
const open = (row: VM.Item) => {
|
||||
source.value = row;
|
||||
form.newName = row.name;
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!source.value) {
|
||||
return;
|
||||
}
|
||||
if (!form.newName || form.newName === source.value.name) {
|
||||
MsgError(i18n.global.t('xpack.vm.renameHelper'));
|
||||
return;
|
||||
}
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('xpack.vm.renameConfirm', [source.value.name, form.newName]),
|
||||
i18n.global.t('xpack.vm.rename'),
|
||||
);
|
||||
const taskID = newUUID();
|
||||
loading.value = true;
|
||||
await renameVM({
|
||||
taskID,
|
||||
name: source.value.name,
|
||||
newName: form.newName,
|
||||
})
|
||||
.then((res) => {
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
emit('task-success', res.data?.taskID || taskID);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
@@ -0,0 +1,319 @@
|
||||
<template>
|
||||
<DrawerPro
|
||||
v-model="open"
|
||||
:header="$t('xpack.vm.snapshot')"
|
||||
:resource="vmItem?.name || ''"
|
||||
size="large"
|
||||
@close="handleClose"
|
||||
>
|
||||
<template #content>
|
||||
<div v-if="vmItem" v-loading="loading" class="vm-snapshot">
|
||||
<el-alert type="info" :closable="false" :title="$t('xpack.vm.snapshotHelper')" />
|
||||
<div class="vm-snapshot-toolbar">
|
||||
<el-button v-permission type="primary" @click="openCreate">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-timeline v-if="items.length" class="snapshot-timeline">
|
||||
<el-timeline-item
|
||||
v-for="row in items"
|
||||
:key="row.name"
|
||||
:timestamp="formatDate(row.createdAt)"
|
||||
placement="top"
|
||||
:type="row.current ? 'success' : 'primary'"
|
||||
:hollow="!row.current"
|
||||
>
|
||||
<article class="snapshot-card" :class="{ 'is-current': row.current }">
|
||||
<div class="snapshot-card-header">
|
||||
<div class="snapshot-title">
|
||||
<span class="snapshot-name" :title="row.name">{{ row.name }}</span>
|
||||
<el-tag v-if="row.current" size="small" type="success">
|
||||
{{ $t('xpack.vm.currentSnapshot') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="snapshot-actions">
|
||||
<el-button
|
||||
v-permission
|
||||
link
|
||||
type="primary"
|
||||
:disabled="vmNotShutOff"
|
||||
@click="recoverSnapshot(row)"
|
||||
>
|
||||
{{ $t('commons.button.recover') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="snapshot-meta">
|
||||
<span>
|
||||
{{ $t('xpack.vm.snapshotSize') }}:{{ row.size ? computeSize(row.size) : '-' }}
|
||||
</span>
|
||||
<span class="snapshot-description" :title="row.description">
|
||||
{{ $t('commons.table.description') }}:{{ row.description || '-' }}
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</el-timeline-item>
|
||||
</el-timeline>
|
||||
<el-empty v-if="!items.length && !loading" :description="$t('xpack.vm.snapshotEmpty')" />
|
||||
</div>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
|
||||
<DialogPro v-model="createVisible" :title="$t('xpack.vm.createSnapshot')" size="small">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top">
|
||||
<el-form-item :label="$t('xpack.vm.snapshot')" prop="snapshotName">
|
||||
<el-input v-model.trim="form.snapshotName" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<span class="dialog-footer">
|
||||
<el-button @click="createVisible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button v-permission type="primary" :loading="submitLoading" @click="submitCreate">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</span>
|
||||
</template>
|
||||
</DialogPro>
|
||||
|
||||
<TaskLog ref="taskLogRef" width="70%" @close="search" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { ElMessageBox, type FormInstance } from 'element-plus';
|
||||
import DrawerPro from '@/components/drawer-pro/index.vue';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { dateFormat } from '@/utils/date';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { createVMSnapshot, listVMSnapshots, recoverVMSnapshot } from '@/api/modules/vm';
|
||||
|
||||
const open = ref(false);
|
||||
const createVisible = ref(false);
|
||||
const loading = ref(false);
|
||||
const submitLoading = ref(false);
|
||||
const vmItem = ref<VM.Item>();
|
||||
const items = ref<VM.SnapshotItem[]>([]);
|
||||
const formRef = ref<FormInstance>();
|
||||
const taskLogRef = ref();
|
||||
const form = reactive({
|
||||
snapshotName: '',
|
||||
description: '',
|
||||
});
|
||||
|
||||
const rules = reactive({
|
||||
snapshotName: [Rules.requiredInput, Rules.vmName],
|
||||
});
|
||||
|
||||
const vmNotShutOff = computed(() => vmItem.value?.status !== 'shut off');
|
||||
|
||||
const acceptParams = (row: VM.Item) => {
|
||||
vmItem.value = row;
|
||||
open.value = true;
|
||||
search();
|
||||
};
|
||||
|
||||
const search = async () => {
|
||||
if (!vmItem.value?.name) {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await listVMSnapshots(vmItem.value.name)
|
||||
.then((res) => {
|
||||
items.value = res.data || [];
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
form.snapshotName = defaultSnapshotName();
|
||||
form.description = '';
|
||||
createVisible.value = true;
|
||||
};
|
||||
|
||||
const submitCreate = async () => {
|
||||
if (!vmItem.value?.name) {
|
||||
return;
|
||||
}
|
||||
await formRef.value?.validate();
|
||||
const taskID = newUUID();
|
||||
submitLoading.value = true;
|
||||
await createVMSnapshot({
|
||||
taskID,
|
||||
name: vmItem.value.name,
|
||||
snapshotName: form.snapshotName,
|
||||
description: form.description,
|
||||
})
|
||||
.then((res) => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
createVisible.value = false;
|
||||
openTaskLog(res.data?.taskID || taskID);
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const recoverSnapshot = async (row: VM.SnapshotItem) => {
|
||||
if (!vmItem.value?.name) {
|
||||
return;
|
||||
}
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('xpack.vm.snapshotRecoverConfirm', [row.name, vmItem.value.name]),
|
||||
i18n.global.t('xpack.vm.recoverSnapshot'),
|
||||
{
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
},
|
||||
);
|
||||
const taskID = newUUID();
|
||||
loading.value = true;
|
||||
await recoverVMSnapshot({ taskID, name: vmItem.value.name, snapshotName: row.name })
|
||||
.then((res) => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
openTaskLog(res.data?.taskID || taskID);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
items.value = [];
|
||||
vmItem.value = undefined;
|
||||
};
|
||||
|
||||
const openTaskLog = (taskID: string) => {
|
||||
taskLogRef.value?.openWithTaskID(taskID, true);
|
||||
};
|
||||
|
||||
const formatDate = (value: number) => (value ? dateFormat(null, null, value * 1000) : '-');
|
||||
|
||||
const defaultSnapshotName = () => {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => String(value).padStart(2, '0');
|
||||
return `snap-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}${pad(now.getHours())}${pad(
|
||||
now.getMinutes(),
|
||||
)}${pad(now.getSeconds())}`;
|
||||
};
|
||||
|
||||
defineExpose({
|
||||
acceptParams,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-snapshot {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vm-snapshot-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snapshot-timeline {
|
||||
margin: 4px 0 0;
|
||||
padding-left: 0;
|
||||
|
||||
:deep(.el-timeline-item__timestamp) {
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-card {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
padding: 14px 16px;
|
||||
margin-bottom: 12px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.2s;
|
||||
|
||||
&.is-current {
|
||||
border-color: var(--el-color-success-light-5);
|
||||
background: var(--el-color-success-light-9);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-card-header,
|
||||
.snapshot-title,
|
||||
.snapshot-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.snapshot-card-header {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.snapshot-title {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.snapshot-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.snapshot-actions {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.snapshot-meta {
|
||||
min-width: 0;
|
||||
gap: 8px 24px;
|
||||
margin-top: 8px;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.snapshot-description {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.snapshot-card-header,
|
||||
.snapshot-meta {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.snapshot-card-header {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.snapshot-meta {
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<DialogPro v-model="visible" :title="$t('xpack.vm.convertToTemplate') + ' - ' + (source?.name || '')" size="small">
|
||||
<el-form ref="formRef" v-loading="storageLoading" label-position="top" :model="form" :rules="rules">
|
||||
<el-form-item :label="$t('xpack.vm.templateName')" prop="templateName">
|
||||
<el-input v-model.trim="form.templateName" :maxlength="vmNameMaxLength" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.storagePool')">
|
||||
<el-select v-model="form.storagePool" class="full-width" filterable>
|
||||
<el-option
|
||||
v-for="item in storageItems"
|
||||
:key="item.id"
|
||||
:label="item.path ? `${item.name} (${item.path})` : item.name"
|
||||
:value="item.name"
|
||||
:disabled="isVMResourceUnavailable(item.status)"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button
|
||||
v-permission
|
||||
type="primary"
|
||||
:loading="loading"
|
||||
:disabled="storageLoading || selectedStorageUnavailable"
|
||||
@click="submit"
|
||||
>
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DialogPro>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref } from 'vue';
|
||||
import { type FormInstance, type FormRules } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { isVMResourceUnavailable } from '@/utils/vm';
|
||||
import { createVMTemplate } from '@/api/modules/vm';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
|
||||
const props = defineProps<{
|
||||
storageItems: VM.StorageItem[];
|
||||
storageLoading?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'task-success', taskID: string): void;
|
||||
}>();
|
||||
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const source = ref<VM.Item>();
|
||||
const formRef = ref<FormInstance>();
|
||||
const form = reactive({
|
||||
templateName: '',
|
||||
storagePool: 'default',
|
||||
description: '',
|
||||
});
|
||||
const selectedStorageUnavailable = computed(() =>
|
||||
isVMResourceUnavailable(props.storageItems.find((item) => item.name === form.storagePool)?.status),
|
||||
);
|
||||
const rules = reactive<FormRules>({
|
||||
templateName: [Rules.requiredInput, Rules.vmName],
|
||||
});
|
||||
|
||||
const templateSuffix = '-tpl';
|
||||
const vmNameMaxLength = 64;
|
||||
|
||||
const defaultTemplateName = (name: string) => {
|
||||
const maxBaseLength = vmNameMaxLength - templateSuffix.length;
|
||||
return `${name.slice(0, maxBaseLength)}${templateSuffix}`;
|
||||
};
|
||||
|
||||
const open = (row: VM.Item) => {
|
||||
source.value = row;
|
||||
form.templateName = defaultTemplateName(row.name);
|
||||
form.storagePool = row.storagePool || props.storageItems[0]?.name || 'default';
|
||||
form.description = row.description || '';
|
||||
visible.value = true;
|
||||
nextTick(() => formRef.value?.clearValidate());
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!source.value || !formRef.value) {
|
||||
return;
|
||||
}
|
||||
if (props.storageLoading || selectedStorageUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
await formRef.value.validate();
|
||||
const taskID = newUUID();
|
||||
loading.value = true;
|
||||
await createVMTemplate({
|
||||
taskID,
|
||||
name: source.value.name,
|
||||
templateName: form.templateName,
|
||||
storagePool: form.storagePool,
|
||||
description: form.description,
|
||||
})
|
||||
.then((res) => {
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
emit('task-success', res.data?.taskID || taskID);
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<DrawerPro
|
||||
v-model="visible"
|
||||
:header="$t('commons.button.create')"
|
||||
size="large"
|
||||
:auto-close="!loading"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" v-loading="bridgeLoading" label-position="top" :model="form" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model.trim="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.type')" prop="type">
|
||||
<el-radio-group v-model="form.type">
|
||||
<el-radio-button label="nat">NAT</el-radio-button>
|
||||
<el-radio-button label="bridge">Bridge</el-radio-button>
|
||||
<el-radio-button label="macvtap">Macvtap</el-radio-button>
|
||||
</el-radio-group>
|
||||
<span class="input-help">
|
||||
{{ $t(`xpack.vm.${form.type}NetworkHelper`) }}
|
||||
</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'bridge'" :label="$t('xpack.vm.bridgeName')" prop="bridgeName">
|
||||
<el-select v-model="form.bridgeName" class="full-width" filterable default-first-option>
|
||||
<el-option
|
||||
v-for="item in bridgeOptions"
|
||||
:key="item.name"
|
||||
:label="item.description ? `${item.name} (${item.description})` : item.name"
|
||||
:value="item.name"
|
||||
:disabled="!item.recommended"
|
||||
>
|
||||
<span>{{ item.name }}</span>
|
||||
<span v-if="item.description" class="option-description">{{ item.description }}</span>
|
||||
</el-option>
|
||||
</el-select>
|
||||
<span class="input-help">{{ $t('xpack.vm.bridgeNameHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="form.type === 'macvtap'" :label="$t('xpack.vm.sourceInterface')" prop="sourceInterface">
|
||||
<el-select v-model="form.sourceInterface" class="full-width" filterable>
|
||||
<el-option
|
||||
v-for="item in interfaceOptions"
|
||||
:key="item.name"
|
||||
:label="item.addresses.length ? `${item.name} (${item.addresses.join(', ')})` : item.name"
|
||||
:value="item.name"
|
||||
/>
|
||||
</el-select>
|
||||
<span class="input-help">{{ $t('xpack.vm.macvtapInterfaceHelper') }}</span>
|
||||
</el-form-item>
|
||||
<template v-if="form.type === 'nat'">
|
||||
<el-form-item :label="$t('xpack.vm.networkCIDR')" prop="cidr">
|
||||
<el-input v-model.trim="form.cidr" />
|
||||
<span class="input-help">{{ $t('xpack.vm.natCIDRHelper') }}</span>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.gateway')" prop="gateway">
|
||||
<el-input v-model.trim="form.gateway" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.dhcpStart')" prop="dhcpStart">
|
||||
<el-input v-model.trim="form.dhcpStart" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.dhcpEnd')" prop="dhcpEnd">
|
||||
<el-input v-model.trim="form.dhcpEnd" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.autoStart">{{ $t('xpack.vm.resourceAutoStart') }}</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="loading" @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button v-permission type="primary" :disabled="loading || bridgeLoading" @click="submit">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<div v-if="visible && loading" v-loading="loading" class="vm-drawer-loading-mask"></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref, watch } from 'vue';
|
||||
import { type FormInstance, type FormRules } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { createVMNetwork, listVMNetworkBridges, listVMNetworkInterfaces } from '@/api/modules/vm';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const bridgeLoading = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const bridgeOptions = ref<VM.NetworkBridgeItem[]>([]);
|
||||
const interfaceOptions = ref<VM.NetworkInterfaceItem[]>([]);
|
||||
|
||||
const form = reactive<VM.NetworkCreate & { id: number }>({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: 'nat',
|
||||
bridgeName: '',
|
||||
sourceInterface: '',
|
||||
cidr: '192.168.100.0/24',
|
||||
gateway: '192.168.100.1',
|
||||
dhcpStart: '192.168.100.2',
|
||||
dhcpEnd: '192.168.100.254',
|
||||
autoStart: true,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const validateAvailableBridgeName = (_rule: unknown, value: string, callback: (error?: Error) => void) => {
|
||||
if (form.type !== 'bridge' || !value) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
const bridge = bridgeOptions.value.find((item) => item.name === value);
|
||||
if (bridge && !bridge.recommended) {
|
||||
callback(new Error(i18n.global.t('xpack.vm.bridgeName') + ' ' + i18n.global.t('commons.status.used')));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
};
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [Rules.requiredInput, Rules.vmNetwork],
|
||||
value: [{ required: true, message: i18n.global.t('commons.rule.requiredInput'), trigger: 'blur' }],
|
||||
bridgeName: [
|
||||
Rules.requiredInput,
|
||||
Rules.vmNetwork,
|
||||
{ validator: validateAvailableBridgeName, trigger: ['blur', 'change'] },
|
||||
],
|
||||
sourceInterface: [Rules.requiredSelect],
|
||||
cidr: [Rules.requiredInput],
|
||||
gateway: [Rules.requiredInput, Rules.ipv4],
|
||||
dhcpStart: [Rules.requiredInput, Rules.ipv4],
|
||||
dhcpEnd: [Rules.requiredInput, Rules.ipv4],
|
||||
});
|
||||
|
||||
const acceptParams = () => {
|
||||
resetForm();
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const loadBridgeOptions = async () => {
|
||||
bridgeLoading.value = true;
|
||||
await listVMNetworkBridges()
|
||||
.then((res) => {
|
||||
bridgeOptions.value = res.data || [];
|
||||
})
|
||||
.catch(() => {
|
||||
bridgeOptions.value = [];
|
||||
})
|
||||
.finally(() => {
|
||||
bridgeLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const loadInterfaceOptions = async () => {
|
||||
interfaceOptions.value = [];
|
||||
bridgeLoading.value = true;
|
||||
await listVMNetworkInterfaces()
|
||||
.then((res) => {
|
||||
interfaceOptions.value = res.data || [];
|
||||
})
|
||||
.catch(() => {
|
||||
interfaceOptions.value = [];
|
||||
})
|
||||
.finally(() => {
|
||||
bridgeLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
await createVMNetwork({ ...form })
|
||||
.then(() => {
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
})
|
||||
.finally(() => {
|
||||
emit('search');
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0;
|
||||
form.name = '';
|
||||
form.type = 'nat';
|
||||
form.bridgeName = '';
|
||||
form.sourceInterface = '';
|
||||
form.cidr = '192.168.100.0/24';
|
||||
form.gateway = '192.168.100.1';
|
||||
form.dhcpStart = '192.168.100.2';
|
||||
form.dhcpEnd = '192.168.100.254';
|
||||
form.autoStart = true;
|
||||
form.description = '';
|
||||
bridgeOptions.value = [];
|
||||
interfaceOptions.value = [];
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
watch(
|
||||
() => form.type,
|
||||
(type) => {
|
||||
form.bridgeName = '';
|
||||
form.sourceInterface = '';
|
||||
formRef.value?.clearValidate();
|
||||
if (type === 'bridge') {
|
||||
loadBridgeOptions();
|
||||
} else if (type === 'macvtap') {
|
||||
loadInterfaceOptions();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.option-description {
|
||||
color: var(--el-text-color-secondary);
|
||||
float: right;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-drawer-loading-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000;
|
||||
width: 50%;
|
||||
background: var(--el-mask-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,528 @@
|
||||
<template>
|
||||
<LayoutContent :title="$t('xpack.vm.network')">
|
||||
<template #leftToolBar>
|
||||
<el-button v-permission type="primary" :disabled="vmUnavailable" @click="openCreate">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template #rightToolBar>
|
||||
<TableSearch v-model:searchName="searchName" @search="search" />
|
||||
<TableRefresh @search="search" />
|
||||
</template>
|
||||
<template #main>
|
||||
<div class="network-card-list" v-if="items.length" v-loading="loading">
|
||||
<article v-for="item in items" :key="item.name" class="network-card">
|
||||
<div class="network-card__header">
|
||||
<div class="network-card__title">
|
||||
<span class="network-card__name" :title="item.name">{{ item.name }}</span>
|
||||
<el-tag round size="small" effect="plain">{{ formatType(item.type) }}</el-tag>
|
||||
</div>
|
||||
<el-dropdown v-permission placement="bottom" :disabled="loading || !canToggleStatus(item)">
|
||||
<span class="network-card__status">
|
||||
<Status
|
||||
:status="item.status || RESOURCE_STATUS_UNKNOWN"
|
||||
:msg="item.error"
|
||||
:operate="canToggleStatus(item)"
|
||||
:disabled="statusSwitchingIds.includes(item.id)"
|
||||
/>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
:disabled="
|
||||
statusSwitchingIds.includes(item.id) ||
|
||||
normalizeStatus(item.status) === 'running'
|
||||
"
|
||||
@click="changeStatus(item, 'running')"
|
||||
>
|
||||
{{ $t('commons.operate.start') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:disabled="
|
||||
statusSwitchingIds.includes(item.id) ||
|
||||
normalizeStatus(item.status) === 'inactive'
|
||||
"
|
||||
@click="changeStatus(item, 'inactive')"
|
||||
>
|
||||
{{ $t('commons.operate.stop') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="network-card__sub">
|
||||
<span v-if="item.type === 'nat'">{{ item.cidr || '-' }}</span>
|
||||
<span v-else-if="item.type === 'macvtap'">{{ item.sourceInterface || '-' }}</span>
|
||||
<span v-else>{{ item.bridgeName || '-' }}</span>
|
||||
</div>
|
||||
<div class="network-card__meta">
|
||||
<div class="network-card__meta-item">
|
||||
<span class="network-card__meta-label">{{ $t('xpack.vm.resourceAutoStart') }}</span>
|
||||
<el-switch
|
||||
v-permission
|
||||
size="small"
|
||||
v-model="item.autoStart"
|
||||
:disabled="!canToggleStatus(item)"
|
||||
:loading="switchingIds.includes(item.id)"
|
||||
@change="changeAutoStart(item, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="network-card__meta-item">
|
||||
<span class="network-card__meta-label">{{ $t('xpack.vm.vmCount') }}</span>
|
||||
<el-dropdown
|
||||
v-if="item.vmCount"
|
||||
class="network-vm-dropdown"
|
||||
placement="bottom"
|
||||
trigger="click"
|
||||
@command="goVMList"
|
||||
>
|
||||
<el-button class="network-card__meta-link" link type="primary">
|
||||
{{ item.vmCount || 0 }}
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="vm in item.vms || []"
|
||||
:key="vm.id"
|
||||
:command="vm.name"
|
||||
class="network-vm-dropdown-item"
|
||||
>
|
||||
{{ vm.name }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span v-else class="network-card__meta-value">0</span>
|
||||
</div>
|
||||
<div class="network-card__meta-item">
|
||||
<span class="network-card__meta-label">{{ $t('xpack.vm.usedIPs') }}</span>
|
||||
<span
|
||||
v-if="item.type === 'bridge' || item.type === 'macvtap'"
|
||||
class="network-card__meta-value"
|
||||
:title="$t('xpack.vm.usedIPsUnavailable')"
|
||||
>
|
||||
{{ $t('xpack.vm.usedIPsUnavailable') }}
|
||||
</span>
|
||||
<el-button
|
||||
v-else
|
||||
class="network-card__meta-link"
|
||||
link
|
||||
type="primary"
|
||||
@click="openUsedIPs(item)"
|
||||
>
|
||||
{{ item.usedIPs?.length || 0 }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-card__detail">
|
||||
<div class="network-card__detail-item" v-if="item.type === 'nat'">
|
||||
<span>{{ $t('xpack.vm.gateway') }}</span>
|
||||
<strong>{{ item.gateway || '-' }}</strong>
|
||||
</div>
|
||||
<div class="network-card__detail-item" v-if="item.type === 'nat'">
|
||||
<span>DHCP</span>
|
||||
<strong>{{ formatDHCP(item) }}</strong>
|
||||
</div>
|
||||
<div class="network-card__detail-item" v-if="item.type === 'bridge'">
|
||||
<span>{{ $t('xpack.vm.bridgeName') }}</span>
|
||||
<strong>{{ item.bridgeName || '-' }}</strong>
|
||||
</div>
|
||||
<div class="network-card__detail-item" v-if="item.type === 'macvtap'">
|
||||
<span>{{ $t('xpack.vm.sourceInterface') }}</span>
|
||||
<strong>{{ item.sourceInterface || '-' }}</strong>
|
||||
</div>
|
||||
<div class="network-card__detail-item network-card__editable">
|
||||
<span>{{ $t('commons.table.description') }}</span>
|
||||
<strong :title="item.description">{{ item.description || '-' }}</strong>
|
||||
<el-button
|
||||
v-permission
|
||||
link
|
||||
type="primary"
|
||||
icon="EditPen"
|
||||
@click="openDescriptionEdit(item)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="network-card__actions">
|
||||
<el-button v-permission link type="primary" @click="openDelete(item)">
|
||||
{{ $t('commons.button.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else :description="$t('commons.msg.noneData')" />
|
||||
</template>
|
||||
</LayoutContent>
|
||||
<Operate ref="operateRef" @search="search" />
|
||||
<ResourceDelete ref="deleteRef" resource-type="network" @deleted="search" />
|
||||
<el-dialog
|
||||
v-model="usedIPDialogVisible"
|
||||
:title="`${selectedNetwork?.name || ''} - ${$t('xpack.vm.usedIPs')}`"
|
||||
width="720px"
|
||||
>
|
||||
<ComplexTable :data="selectedNetwork?.usedIPs || []">
|
||||
<el-table-column prop="ip" label="IP" min-width="150" />
|
||||
<el-table-column prop="vmName" :label="$t('commons.table.name')" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column prop="mac" label="MAC" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column prop="source" :label="$t('xpack.vm.network')" min-width="150" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.source || '-' }}</template>
|
||||
</el-table-column>
|
||||
</ComplexTable>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { listVMNetworks, updateVMNetwork } from '@/api/modules/vm';
|
||||
import Operate from '@/views/vm/network/create/index.vue';
|
||||
import ResourceDelete from '@/views/vm/components/resource-delete.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
vmUnavailable: boolean;
|
||||
vmEnvironmentLoaded: boolean;
|
||||
}>();
|
||||
const loading = ref(false);
|
||||
const searchName = ref('');
|
||||
const items = ref<VM.NetworkItem[]>([]);
|
||||
const switchingIds = ref<number[]>([]);
|
||||
const statusSwitchingIds = ref<number[]>([]);
|
||||
const operateRef = ref();
|
||||
const deleteRef = ref<InstanceType<typeof ResourceDelete>>();
|
||||
const usedIPDialogVisible = ref(false);
|
||||
const selectedNetwork = ref<VM.NetworkItem>();
|
||||
const vmUnavailable = computed(() => props.vmUnavailable);
|
||||
const router = useRouter();
|
||||
const RESOURCE_STATUS_RUNNING = 'running';
|
||||
const RESOURCE_STATUS_INACTIVE = 'inactive';
|
||||
const RESOURCE_STATUS_DELETED = 'deleted';
|
||||
const RESOURCE_STATUS_UNKNOWN = 'unknown';
|
||||
|
||||
const search = async () => {
|
||||
if (!props.vmEnvironmentLoaded || vmUnavailable.value) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await listVMNetworks()
|
||||
.then((res) => {
|
||||
const keyword = searchName.value.trim().toLowerCase();
|
||||
items.value = keyword
|
||||
? (res.data || []).filter((item) => item.name.toLowerCase().includes(keyword))
|
||||
: res.data || [];
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (vmUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
operateRef.value?.acceptParams();
|
||||
};
|
||||
|
||||
const openUsedIPs = (row: VM.NetworkItem) => {
|
||||
selectedNetwork.value = row;
|
||||
usedIPDialogVisible.value = true;
|
||||
};
|
||||
|
||||
const goVMList = (name: string) => {
|
||||
router.push({ path: '/vm/list', query: { name } });
|
||||
};
|
||||
|
||||
const changeAutoStart = async (row: VM.NetworkItem, value: string | number | boolean) => {
|
||||
if (!canToggleStatus(row)) {
|
||||
row.autoStart = !Boolean(value);
|
||||
return;
|
||||
}
|
||||
switchingIds.value.push(row.id);
|
||||
const nextValue = Boolean(value);
|
||||
loading.value = true;
|
||||
await updateVMNetwork({ id: row.id, autoStart: nextValue })
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
})
|
||||
.catch((error) => {
|
||||
row.autoStart = !nextValue;
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
switchingIds.value = switchingIds.value.filter((id) => id !== row.id);
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const canToggleStatus = (row: VM.NetworkItem) => {
|
||||
return (
|
||||
resourceExists(row.status) &&
|
||||
[RESOURCE_STATUS_RUNNING, RESOURCE_STATUS_INACTIVE].includes(normalizeStatus(row.status))
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeStatus = (status: string) => status?.toLowerCase() || '';
|
||||
|
||||
const resourceExists = (status: string) => normalizeStatus(status) !== RESOURCE_STATUS_DELETED;
|
||||
|
||||
const changeStatus = async (row: VM.NetworkItem, status: 'running' | 'inactive') => {
|
||||
if (!canToggleStatus(row) || normalizeStatus(row.status) === status) {
|
||||
return;
|
||||
}
|
||||
const oldStatus = row.status;
|
||||
row.status = status;
|
||||
statusSwitchingIds.value.push(row.id);
|
||||
loading.value = true;
|
||||
await updateVMNetwork({ id: row.id, status })
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
})
|
||||
.catch((error) => {
|
||||
row.status = oldStatus;
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
statusSwitchingIds.value = statusSwitchingIds.value.filter((id) => id !== row.id);
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const openDescriptionEdit = async (row: VM.NetworkItem) => {
|
||||
let res;
|
||||
try {
|
||||
res = await ElMessageBox.prompt(
|
||||
i18n.global.t('commons.table.description'),
|
||||
i18n.global.t('commons.button.edit'),
|
||||
{
|
||||
inputValue: row.description || '',
|
||||
inputType: 'textarea',
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await updateVMNetwork({ id: row.id, description: String(res.value || '') })
|
||||
.then(() => {
|
||||
loading.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const openDelete = (row: VM.NetworkItem) => {
|
||||
deleteRef.value?.open(row);
|
||||
};
|
||||
|
||||
const formatType = (type: string) => {
|
||||
if (type === 'nat') return 'NAT';
|
||||
if (type === 'bridge') return 'Bridge';
|
||||
if (type === 'macvtap') return 'Macvtap';
|
||||
return type || '-';
|
||||
};
|
||||
|
||||
const formatDHCP = (row: VM.NetworkItem) => {
|
||||
if (!row.dhcpStart && !row.dhcpEnd) return '-';
|
||||
return `${row.dhcpStart || '-'} - ${row.dhcpEnd || '-'}`;
|
||||
};
|
||||
|
||||
watch(() => [props.vmEnvironmentLoaded, props.vmUnavailable], search, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.network-card-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(360px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.network-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-card-border-color, var(--el-border-color-lighter));
|
||||
border-radius: 8px;
|
||||
background: var(--el-card-bg-color, var(--el-fill-color-blank));
|
||||
}
|
||||
|
||||
.network-card__header,
|
||||
.network-card__title,
|
||||
.network-card__meta-item,
|
||||
.network-card__detail-item,
|
||||
.network-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.network-card__header {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.network-card__title {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.network-card__status {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.network-card__name {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.network-card__sub {
|
||||
min-height: 22px;
|
||||
margin-top: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.network-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.network-card__meta-item {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.network-card__detail-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.network-card__meta-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.network-card__meta-value {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.network-card__meta-value.is-enabled {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.network-card__meta-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.network-card__meta-item :deep(.el-switch) {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.network-card__detail {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.network-card__detail-item {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.network-card__detail-item strong {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.network-card__editable .el-button {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.network-card__detail-item.network-card__editable strong {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.network-card__detail-item.network-card__editable > span {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.network-card__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.network-card__actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.network-vm-dropdown {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:global(.network-vm-dropdown-item) {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
:global(.network-vm-dropdown-item span) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.network-card-list {
|
||||
grid-template-columns: repeat(2, minmax(360px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.network-card-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,676 @@
|
||||
<template>
|
||||
<LayoutContent v-loading="loading" :title="$t('xpack.vm.overview')">
|
||||
<template #main>
|
||||
<div class="vm-overview">
|
||||
<section class="vm-hero">
|
||||
<div class="vm-health">
|
||||
<div class="vm-health__top">
|
||||
<div>
|
||||
<div class="vm-section-label">
|
||||
{{ $t('xpack.vm.overview') }}
|
||||
</div>
|
||||
<div class="vm-health__title">{{ $t('xpack.vm.title') }}</div>
|
||||
</div>
|
||||
<el-button type="primary" :disabled="vmUnavailable" @click="orphanCleanRef?.open()">
|
||||
{{ $t('xpack.vm.orphanClean') }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="vm-health__meta">
|
||||
<span>
|
||||
{{ $t('xpack.vm.runtimeHealth') }}
|
||||
<strong :class="{ danger: !environmentReady }">{{ environmentStatusText }}</strong>
|
||||
</span>
|
||||
<span>{{ $t('xpack.vm.vm') }} {{ vms.length }}</span>
|
||||
<span>{{ $t('xpack.vm.storagePool') }} {{ storages.length }}</span>
|
||||
<span>{{ $t('xpack.vm.riskReminder') }} {{ riskItems.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="vm-stat-grid">
|
||||
<div v-for="item in statusCards" :key="item.key" class="vm-stat-card">
|
||||
<div class="vm-stat-card__label">{{ item.label }}</div>
|
||||
<div class="vm-stat-card__value">{{ item.value }}</div>
|
||||
<div class="vm-stat-card__extra">{{ item.extra }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="vm-overview-grid">
|
||||
<el-card shadow="never" class="vm-panel vm-resource-panel">
|
||||
<template #header>
|
||||
<div class="vm-panel__header">
|
||||
<span>{{ $t('xpack.vm.resourceOverview') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="vm-resource-list">
|
||||
<div v-for="item in resourceCards" :key="item.key" class="vm-resource-row">
|
||||
<div class="vm-resource-row__head">
|
||||
<div>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.value }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<el-progress :percentage="item.percent" :status="progressStatus(item.percent)" />
|
||||
<div class="vm-resource-row__meta">{{ item.meta }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="vm-panel">
|
||||
<template #header>{{ $t('xpack.vm.riskReminder') }}</template>
|
||||
<div v-if="riskItems.length" class="vm-risk-list">
|
||||
<div v-for="item in riskItems" :key="item.key" class="vm-risk-item" :class="item.type">
|
||||
<el-icon><WarningFilled /></el-icon>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.message }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else :description="$t('xpack.vm.noRisk')" />
|
||||
</el-card>
|
||||
</section>
|
||||
|
||||
<section class="vm-storage-section">
|
||||
<el-card shadow="never" class="vm-panel">
|
||||
<template #header>
|
||||
<div class="vm-panel__header">
|
||||
<span>{{ $t('xpack.vm.storageOverview') }}</span>
|
||||
<el-button link type="primary" @click="go('/vm/storage-pools')">
|
||||
{{ $t('xpack.vm.storagePool') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<div v-if="storages.length" class="vm-storage-grid">
|
||||
<div v-for="item in storages" :key="item.id" class="vm-storage-card">
|
||||
<div class="vm-storage-card__head">
|
||||
<div>
|
||||
<strong>{{ item.name }}</strong>
|
||||
<span>{{ item.path || '-' }}</span>
|
||||
</div>
|
||||
<Status :status="resourceStatus(item)" />
|
||||
</div>
|
||||
<el-progress
|
||||
:percentage="normalizePercent(storageUsedPercent(item))"
|
||||
:status="progressStatus(storageUsedPercent(item))"
|
||||
/>
|
||||
<div class="vm-storage-card__meta">
|
||||
<span>{{ $t('xpack.vm.available') }} {{ computeSize(item.available || 0) }}</span>
|
||||
<span>{{ $t('xpack.vm.vmCount') }} {{ item.vmCount || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-empty v-else />
|
||||
</el-card>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
</LayoutContent>
|
||||
<OrphanClean ref="orphanCleanRef" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { WarningFilled } from '@element-plus/icons-vue';
|
||||
import OrphanClean from './orphan-clean.vue';
|
||||
import LayoutContent from '@/components/layout-content/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { loadCurrentInfo } from '@/api/modules/dashboard';
|
||||
import type { Dashboard } from '@/api/interface/dashboard';
|
||||
import { GlobalStore } from '@/store';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { checkVMEnvironment, listVMStorages, searchVMs } from '@/api/modules/vm';
|
||||
import { isVMMemoryAllocated } from '@/utils/vm';
|
||||
|
||||
const BYTE_PER_MIB = 1024 * 1024;
|
||||
|
||||
const props = defineProps<{
|
||||
vmUnavailable: boolean;
|
||||
vmEnvironmentLoaded: boolean;
|
||||
}>();
|
||||
const router = useRouter();
|
||||
const globalStore = GlobalStore();
|
||||
const loading = ref(false);
|
||||
const orphanCleanRef = ref<InstanceType<typeof OrphanClean>>();
|
||||
const vms = ref<VM.Item[]>([]);
|
||||
const storages = ref<VM.StorageItem[]>([]);
|
||||
const currentInfo = ref<Dashboard.CurrentInfo>();
|
||||
const defaultEnvironment = (): VM.Environment => ({
|
||||
arch: '',
|
||||
archSupported: true,
|
||||
ready: false,
|
||||
helperExist: false,
|
||||
helperMessage: '',
|
||||
kvmExist: false,
|
||||
kvmMessage: '',
|
||||
libvirtExist: false,
|
||||
libvirtActive: false,
|
||||
libvirtMessage: '',
|
||||
storageExist: false,
|
||||
storageMessage: '',
|
||||
});
|
||||
const environment = ref<VM.Environment>(defaultEnvironment());
|
||||
const vmUnavailable = computed(() => props.vmUnavailable);
|
||||
|
||||
const environmentReady = computed(() => environment.value.ready);
|
||||
const environmentStatusText = computed(() => {
|
||||
if (!props.vmEnvironmentLoaded) {
|
||||
return i18n.global.t('commons.status.waiting');
|
||||
}
|
||||
return environmentReady.value ? i18n.global.t('commons.status.normal') : i18n.global.t('xpack.vm.runtimeAbnormal');
|
||||
});
|
||||
const runningCount = computed(() => vms.value.filter((item) => ['running', 'blocked'].includes(item.status)).length);
|
||||
const stoppedCount = computed(() => vms.value.filter((item) => item.status === 'shut off').length);
|
||||
const creatingCount = computed(() => vms.value.filter((item) => item.status?.toLowerCase() === 'creating').length);
|
||||
const pausedCount = computed(() => vms.value.filter((item) => item.status === 'paused').length);
|
||||
const abnormalCount = computed(
|
||||
() =>
|
||||
vms.value.filter(
|
||||
(item) =>
|
||||
!['running', 'blocked', 'shut off', 'paused', 'creating', 'shutdown', 'pmsuspended'].includes(
|
||||
item.status,
|
||||
),
|
||||
).length,
|
||||
);
|
||||
|
||||
const statusCards = computed(() => [
|
||||
{
|
||||
key: 'running',
|
||||
label: i18n.global.t('commons.status.running'),
|
||||
value: runningCount.value,
|
||||
extra: `${percent(runningCount.value, vms.value.length)}%`,
|
||||
},
|
||||
{
|
||||
key: 'stopped',
|
||||
label: i18n.global.t('commons.status.stopped'),
|
||||
value: stoppedCount.value,
|
||||
extra: `${percent(stoppedCount.value, vms.value.length)}%`,
|
||||
},
|
||||
{
|
||||
key: 'creating',
|
||||
label: i18n.global.t('commons.status.creating'),
|
||||
value: creatingCount.value,
|
||||
extra: i18n.global.t('xpack.vm.inProgress'),
|
||||
},
|
||||
{
|
||||
key: 'abnormal',
|
||||
label: i18n.global.t('xpack.vm.abnormal'),
|
||||
value: abnormalCount.value,
|
||||
extra: abnormalCount.value ? i18n.global.t('xpack.vm.needAttention') : i18n.global.t('xpack.vm.normal'),
|
||||
},
|
||||
]);
|
||||
|
||||
const cpuAllocated = computed(() => vms.value.reduce((total, item) => total + (Number(item.cpu) || 0), 0));
|
||||
const cpuTotal = computed(() => currentInfo.value?.cpuTotal || 0);
|
||||
const memoryAllocated = computed(() =>
|
||||
vms.value.reduce((total, item) => total + (Number(item.memory) || 0) * BYTE_PER_MIB, 0),
|
||||
);
|
||||
const memoryTotal = computed(() => currentInfo.value?.memoryTotal || 0);
|
||||
const runningMemoryAllocated = computed(() =>
|
||||
vms.value
|
||||
.filter((item) => isVMMemoryAllocated(item.status))
|
||||
.reduce((total, item) => total + (Number(item.memory) || 0) * BYTE_PER_MIB, 0),
|
||||
);
|
||||
const normalizePath = (path?: string) => {
|
||||
const value = (path || '').trim();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
return value.replace(/\/+/g, '/').replace(/\/$/, '') || '/';
|
||||
};
|
||||
const isPathInDir = (path: string, dir: string) => dir === '/' || path === dir || path.startsWith(`${dir}/`);
|
||||
const storageRowsForSummary = computed(() => {
|
||||
const diskData = currentInfo.value?.diskData || [];
|
||||
const rowsByPath = new Map<string, VM.StorageItem>();
|
||||
for (const storage of storages.value) {
|
||||
const storagePath = normalizePath(storage.path);
|
||||
if (!storagePath) {
|
||||
continue;
|
||||
}
|
||||
const disk = diskData
|
||||
.filter((item) => {
|
||||
const diskPath = normalizePath(item.path);
|
||||
return diskPath && isPathInDir(storagePath, diskPath);
|
||||
})
|
||||
.sort((left, right) => normalizePath(right.path).length - normalizePath(left.path).length)[0];
|
||||
const key = normalizePath(disk?.path || storage.path);
|
||||
const existing = rowsByPath.get(key);
|
||||
if (!existing || (storage.capacity || 0) > (existing.capacity || 0)) {
|
||||
rowsByPath.set(key, storage);
|
||||
}
|
||||
}
|
||||
return Array.from(rowsByPath.values());
|
||||
});
|
||||
const storageTotal = computed(() => {
|
||||
if (storageRowsForSummary.value.length) {
|
||||
return storageRowsForSummary.value.reduce((total, item) => total + (item.capacity || 0), 0);
|
||||
}
|
||||
return storages.value.reduce((total, item) => total + (item.capacity || 0), 0);
|
||||
});
|
||||
const storageAvailable = computed(() => {
|
||||
if (storageRowsForSummary.value.length) {
|
||||
return storageRowsForSummary.value.reduce((total, item) => total + (item.available || 0), 0);
|
||||
}
|
||||
return storages.value.reduce((total, item) => total + (item.available || 0), 0);
|
||||
});
|
||||
const storageUsed = computed(() => Math.max(storageTotal.value - storageAvailable.value, 0));
|
||||
|
||||
const normalizePercent = (value: number) => Number(Math.min(Math.max(value || 0, 0), 100).toFixed(2));
|
||||
const percent = (used: number, total: number) => {
|
||||
if (!total) {
|
||||
return 0;
|
||||
}
|
||||
return normalizePercent((used / total) * 100);
|
||||
};
|
||||
|
||||
const cpuPercent = computed(() => percent(cpuAllocated.value, cpuTotal.value));
|
||||
const memoryPercent = computed(() => percent(memoryAllocated.value, memoryTotal.value));
|
||||
const storagePercent = computed(() => percent(storageUsed.value, storageTotal.value));
|
||||
|
||||
const resourceCards = computed(() => [
|
||||
{
|
||||
key: 'cpu',
|
||||
label: i18n.global.t('xpack.vm.cpuAllocated'),
|
||||
value: `${cpuAllocated.value} / ${cpuTotal.value || '-'} ${i18n.global.t('commons.units.core')}`,
|
||||
percent: cpuPercent.value,
|
||||
meta: i18n.global.t('xpack.vm.totalAllocated'),
|
||||
},
|
||||
{
|
||||
key: 'memory',
|
||||
label: i18n.global.t('xpack.vm.memoryAllocated'),
|
||||
value: `${computeSize(memoryAllocated.value)} / ${computeSize(memoryTotal.value)}`,
|
||||
percent: memoryPercent.value,
|
||||
meta: i18n.global.t('xpack.vm.runningAllocated', [computeSize(runningMemoryAllocated.value)]),
|
||||
},
|
||||
{
|
||||
key: 'storage',
|
||||
label: i18n.global.t('xpack.vm.storageUsed'),
|
||||
value: `${computeSize(storageUsed.value)} / ${computeSize(storageTotal.value)}`,
|
||||
percent: storagePercent.value,
|
||||
meta: i18n.global.t('xpack.vm.storageAvailable', [computeSize(storageAvailable.value)]),
|
||||
},
|
||||
]);
|
||||
|
||||
const progressStatus = (value: number) => {
|
||||
if (value >= 90) {
|
||||
return 'exception';
|
||||
}
|
||||
if (value >= 75) {
|
||||
return 'warning';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const storageUsedPercent = (item: VM.StorageItem) => {
|
||||
if (item.usedPercent !== undefined) {
|
||||
return Number(item.usedPercent) || 0;
|
||||
}
|
||||
if (!item.capacity) {
|
||||
return 0;
|
||||
}
|
||||
return ((item.capacity - (item.available || 0)) / item.capacity) * 100;
|
||||
};
|
||||
|
||||
const resourceStatus = (item: VM.StorageItem) => {
|
||||
if (!resourceExists(item.status)) {
|
||||
return item.status || 'deleted';
|
||||
}
|
||||
if (item.status?.toLowerCase() === 'running') {
|
||||
return 'enable';
|
||||
}
|
||||
if (item.status?.toLowerCase() === 'inactive') {
|
||||
return 'stopped';
|
||||
}
|
||||
return item.status || 'unknown';
|
||||
};
|
||||
|
||||
const resourceExists = (status: string) => status?.toLowerCase() !== 'deleted';
|
||||
|
||||
const riskItems = computed(() => {
|
||||
const risks: Array<{
|
||||
key: string;
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'warning' | 'danger';
|
||||
}> = [];
|
||||
if (!environmentReady.value) {
|
||||
risks.push({
|
||||
key: 'environment',
|
||||
title: i18n.global.t('xpack.vm.runtimeAbnormal'),
|
||||
message: i18n.global.t('xpack.vm.environmentRisk'),
|
||||
type: 'danger',
|
||||
});
|
||||
}
|
||||
storages.value
|
||||
.filter((item) => storageUsedPercent(item) >= 80)
|
||||
.forEach((item) => {
|
||||
risks.push({
|
||||
key: `storage-${item.id}`,
|
||||
title: i18n.global.t('xpack.vm.storagePool'),
|
||||
message: i18n.global.t('xpack.vm.storageRisk', [item.name, storageUsedPercent(item).toFixed(2)]),
|
||||
type: storageUsedPercent(item) >= 90 ? 'danger' : 'warning',
|
||||
});
|
||||
});
|
||||
if (abnormalCount.value > 0) {
|
||||
risks.push({
|
||||
key: 'abnormal-vm',
|
||||
title: i18n.global.t('xpack.vm.abnormal'),
|
||||
message: i18n.global.t('xpack.vm.abnormalRisk', [abnormalCount.value]),
|
||||
type: 'danger',
|
||||
});
|
||||
}
|
||||
if (pausedCount.value > 0) {
|
||||
risks.push({
|
||||
key: 'paused-vm',
|
||||
title: i18n.global.t('xpack.vm.suspend'),
|
||||
message: i18n.global.t('xpack.vm.pausedRisk', [pausedCount.value]),
|
||||
type: 'warning',
|
||||
});
|
||||
}
|
||||
if (memoryPercent.value >= 85) {
|
||||
risks.push({
|
||||
key: 'memory',
|
||||
title: i18n.global.t('xpack.vm.memory'),
|
||||
message: i18n.global.t('xpack.vm.memoryRisk', [memoryPercent.value]),
|
||||
type: memoryPercent.value >= 95 ? 'danger' : 'warning',
|
||||
});
|
||||
}
|
||||
return risks;
|
||||
});
|
||||
|
||||
const loadOverview = async () => {
|
||||
if (!props.vmEnvironmentLoaded || vmUnavailable.value) {
|
||||
vms.value = [];
|
||||
storages.value = [];
|
||||
currentInfo.value = undefined;
|
||||
environment.value = defaultEnvironment();
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
try {
|
||||
const [environmentRes, vmRes, storageRes, currentRes] = await Promise.all([
|
||||
checkVMEnvironment(),
|
||||
searchVMs({ page: 1, pageSize: 10000, info: '' }),
|
||||
listVMStorages(),
|
||||
loadCurrentInfo('all', 'all', globalStore.currentNode || 'local'),
|
||||
]);
|
||||
environment.value = environmentRes.data || environment.value;
|
||||
vms.value = vmRes.data.items || [];
|
||||
storages.value = storageRes.data || [];
|
||||
currentInfo.value = currentRes.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const go = (path: string) => {
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
watch(() => [props.vmEnvironmentLoaded, props.vmUnavailable], loadOverview, {
|
||||
immediate: true,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vm-hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(320px, 0.9fr) minmax(0, 1.1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vm-health,
|
||||
.vm-stat-card,
|
||||
.vm-panel {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: var(--el-bg-color);
|
||||
}
|
||||
|
||||
.vm-health {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.vm-health__top,
|
||||
.vm-panel__header,
|
||||
.vm-resource-row__head,
|
||||
.vm-storage-card__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vm-section-label,
|
||||
.vm-stat-card__label,
|
||||
.vm-resource-row__meta,
|
||||
.vm-storage-card__meta,
|
||||
.vm-health__meta,
|
||||
.vm-storage-card__head span,
|
||||
.vm-risk-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-health__title {
|
||||
margin-top: 6px;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.vm-health__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
|
||||
span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
strong.danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.vm-stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vm-stat-card {
|
||||
min-width: 0;
|
||||
padding: 16px;
|
||||
border-top: 3px solid var(--el-border-color);
|
||||
border-top-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.vm-stat-card__value {
|
||||
margin-top: 10px;
|
||||
font-size: 30px;
|
||||
font-weight: 600;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.vm-stat-card__extra {
|
||||
margin-top: 6px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.vm-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.vm-overview-grid--bottom {
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.65fr);
|
||||
}
|
||||
|
||||
.vm-panel {
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 14px 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:deep(.el-card__body) {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-resource-list,
|
||||
.vm-risk-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.vm-resource-row,
|
||||
.vm-risk-item {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.vm-resource-row__head {
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
span,
|
||||
strong {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-resource-row__meta {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vm-storage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vm-storage-card {
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.vm-storage-card__head {
|
||||
margin-bottom: 10px;
|
||||
|
||||
div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.vm-storage-card__meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.vm-risk-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
background: var(--el-color-warning-light-9);
|
||||
border-color: var(--el-color-warning-light-5);
|
||||
|
||||
.el-icon {
|
||||
margin-top: 2px;
|
||||
color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
strong,
|
||||
span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
strong {
|
||||
margin-bottom: 4px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.vm-risk-item.danger {
|
||||
background: var(--el-color-danger-light-9);
|
||||
border-color: var(--el-color-danger-light-5);
|
||||
|
||||
.el-icon {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.vm-hero,
|
||||
.vm-overview-grid,
|
||||
.vm-overview-grid--bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vm-stat-grid,
|
||||
.vm-storage-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.vm-stat-grid,
|
||||
.vm-storage-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<el-dialog v-model="visible" :title="$t('xpack.vm.orphanClean')" width="860px" destroy-on-close>
|
||||
<el-alert :title="$t('xpack.vm.orphanCleanHelper')" type="warning" :closable="false" show-icon />
|
||||
<div class="orphan-clean__toolbar">
|
||||
<span>{{ $t('xpack.vm.orphanSelected', [selected.length, computeSize(selectedSize)]) }}</span>
|
||||
<el-button :loading="scanning" @click="scan">{{ $t('xpack.vm.orphanScan') }}</el-button>
|
||||
</div>
|
||||
<el-table v-loading="scanning" :data="items" max-height="420" @selection-change="selectionChange">
|
||||
<el-table-column type="selection" width="48" />
|
||||
<el-table-column :label="$t('commons.table.name')" min-width="280" show-overflow-tooltip>
|
||||
<template #default="scope">{{ scope.row.path }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.type')" min-width="130">
|
||||
<template #default="scope">{{ typeLabel(scope.row.type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('xpack.vm.orphanSize')" width="120">
|
||||
<template #default="scope">{{ computeSize(scope.row.size) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.date')" width="180">
|
||||
<template #default="scope">{{ formatDate(scope.row.modifiedAt) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<template #footer>
|
||||
<el-button @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button type="primary" :disabled="!selected.length" :loading="cleaning" @click="clean">
|
||||
{{ $t('xpack.vm.orphanClean') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { cleanVMOrphans, scanVMOrphans } from '@/api/modules/vm';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { dateFormat } from '@/utils/date';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
|
||||
const visible = ref(false);
|
||||
const scanning = ref(false);
|
||||
const cleaning = ref(false);
|
||||
const items = ref<VM.OrphanItem[]>([]);
|
||||
const selected = ref<VM.OrphanItem[]>([]);
|
||||
const selectedSize = computed(() => selected.value.reduce((total, item) => total + (item.size || 0), 0));
|
||||
|
||||
const formatDate = (value: number) => (value ? dateFormat(null, null, value * 1000) : '-');
|
||||
const typeLabel = (type: VM.OrphanItem['type']) => i18n.global.t(`xpack.vm.orphanType.${type}`);
|
||||
const selectionChange = (value: VM.OrphanItem[]) => {
|
||||
selected.value = value;
|
||||
};
|
||||
const scan = async () => {
|
||||
scanning.value = true;
|
||||
try {
|
||||
const res = await scanVMOrphans();
|
||||
items.value = res.data || [];
|
||||
selected.value = [];
|
||||
} finally {
|
||||
scanning.value = false;
|
||||
}
|
||||
};
|
||||
const open = async () => {
|
||||
visible.value = true;
|
||||
await scan();
|
||||
};
|
||||
const clean = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
i18n.global.t('xpack.vm.orphanCleanConfirm', [selected.value.length, computeSize(selectedSize.value)]),
|
||||
i18n.global.t('xpack.vm.orphanClean'),
|
||||
{
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
type: 'warning',
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
cleaning.value = true;
|
||||
try {
|
||||
const res = await cleanVMOrphans(selected.value);
|
||||
const removed = res.data || [];
|
||||
const removedPaths = new Set(removed.map((item) => item.path));
|
||||
items.value = items.value.filter((item) => !removedPaths.has(item.path));
|
||||
selected.value = [];
|
||||
MsgSuccess(
|
||||
i18n.global.t('xpack.vm.orphanCleanSuccess', [
|
||||
removed.length,
|
||||
computeSize(removed.reduce((total, item) => total + item.size, 0)),
|
||||
]),
|
||||
);
|
||||
} finally {
|
||||
cleaning.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
defineExpose({ open });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.orphan-clean__toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 16px 0;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,338 @@
|
||||
<template>
|
||||
<div v-loading="loading" class="vm-environment-check">
|
||||
<div v-if="environmentLoaded" class="app-status card-interval">
|
||||
<el-card>
|
||||
<div class="flex w-full flex-col gap-4 md:flex-row">
|
||||
<div class="flex flex-wrap gap-4 ml-3">
|
||||
<el-tag effect="dark" type="success">1Panel-kvm</el-tag>
|
||||
</div>
|
||||
<div v-if="environmentBlocker !== 'helper'" class="mt-0.5">
|
||||
<el-divider direction="vertical" />
|
||||
<el-button
|
||||
type="primary"
|
||||
link
|
||||
:disabled="!hasEnvironmentDetails"
|
||||
@click="dependencyVisible = true"
|
||||
>
|
||||
<el-icon v-if="environmentBlocker" class="vm-dependency-warning">
|
||||
<WarningFilled />
|
||||
</el-icon>
|
||||
{{ $t('xpack.vm.dependencies') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card v-if="maskMessage" class="mask-prompt vm-environment-prompt">
|
||||
<span>{{ maskMessage }}</span>
|
||||
</el-card>
|
||||
|
||||
<div :class="{ mask: vmUnavailable }">
|
||||
<slot
|
||||
:vm-unavailable="vmUnavailable"
|
||||
:vm-environment-loaded="environmentLoaded"
|
||||
:refresh-vm-environment="loadEnvironment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogPro v-model="dependencyVisible" :title="$t('xpack.vm.dependencies')" size="default">
|
||||
<el-alert
|
||||
v-if="environmentBlocker === 'architecture'"
|
||||
class="vm-environment-helper"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('xpack.vm.architectureUnsupported', [environmentStatus.arch])"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="environmentBlocker === 'helper'"
|
||||
class="vm-environment-helper"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('xpack.vm.helperUnavailable')"
|
||||
/>
|
||||
<el-alert
|
||||
v-else-if="environmentBlocker === 'kvm'"
|
||||
class="vm-environment-helper"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
:title="$t('xpack.vm.kvmUnavailable')"
|
||||
>
|
||||
<template #default>
|
||||
<ol class="vm-kvm-helper__list">
|
||||
<li>{{ $t('xpack.vm.kvmCheckCpu') }}</li>
|
||||
<li>{{ $t('xpack.vm.kvmCheckBios') }}</li>
|
||||
<li>{{ $t('xpack.vm.kvmCheckModule') }}</li>
|
||||
<li>{{ $t('xpack.vm.kvmCheckDevice') }}</li>
|
||||
</ol>
|
||||
</template>
|
||||
</el-alert>
|
||||
<template v-else>
|
||||
<el-alert
|
||||
v-if="scriptInstallDependencyItems.length"
|
||||
class="vm-script-library-helper"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<template #title>
|
||||
<span class="vm-script-library-helper__title">
|
||||
<span>{{ $t('cronjob.library.noSuchApp', [scriptInstallDependencyNames]) }}</span>
|
||||
</span>
|
||||
<span class="vm-script-library-helper__link" @click="goScriptLibrary">
|
||||
<el-icon>
|
||||
<Position />
|
||||
</el-icon>
|
||||
<span>{{ $t('firewall.quickJump') }}</span>
|
||||
</span>
|
||||
</template>
|
||||
</el-alert>
|
||||
<ComplexTable :data="installableDependencyItems">
|
||||
<el-table-column prop="name" :label="$t('commons.table.name')" min-width="140" />
|
||||
<el-table-column :label="$t('commons.table.status')" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<Status :status="row.ready ? 'enable' : 'disable'" />
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('xpack.vm.dependencyPurpose')" min-width="260" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ dependencyPurpose(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="message" :label="$t('commons.table.description')" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ dependencyMessage(row) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('commons.table.operate')" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="canEnableDependency(row)"
|
||||
v-permission
|
||||
link
|
||||
type="primary"
|
||||
:loading="environmentEnabling"
|
||||
@click="enableEnvironment"
|
||||
>
|
||||
{{ $t('commons.button.enable') }}
|
||||
</el-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</ComplexTable>
|
||||
<el-empty v-if="!installableDependencyItems.length" :description="$t('xpack.vm.noDependency')" />
|
||||
</template>
|
||||
</DialogPro>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { Position, WarningFilled } from '@element-plus/icons-vue';
|
||||
import i18n from '@/lang';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { checkVMEnvironment, enableVMEnvironment } from '@/api/modules/vm';
|
||||
import { routerToNameWithQuery } from '@/utils/router';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
|
||||
const loading = ref(false);
|
||||
const environmentEnabling = ref(false);
|
||||
const dependencyVisible = ref(false);
|
||||
const environmentLoaded = ref(false);
|
||||
|
||||
interface DependencyItem {
|
||||
name: string;
|
||||
ready: boolean;
|
||||
exist: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
type EnvironmentBlocker = 'architecture' | 'helper' | 'kvm' | 'dependency' | '';
|
||||
|
||||
const defaultEnvironment = (): VM.Environment => ({
|
||||
arch: '',
|
||||
archSupported: true,
|
||||
ready: false,
|
||||
helperExist: false,
|
||||
helperMessage: '',
|
||||
kvmExist: false,
|
||||
kvmMessage: '',
|
||||
libvirtExist: false,
|
||||
libvirtActive: false,
|
||||
libvirtMessage: '',
|
||||
storageExist: false,
|
||||
storageMessage: '',
|
||||
});
|
||||
|
||||
const environmentStatus = ref<VM.Environment>(defaultEnvironment());
|
||||
|
||||
const architectureUnavailable = computed(() => environmentLoaded.value && !environmentStatus.value.archSupported);
|
||||
const helperUnavailable = computed(() => environmentLoaded.value && !environmentStatus.value.helperExist);
|
||||
const kvmUnavailable = computed(
|
||||
() =>
|
||||
environmentLoaded.value &&
|
||||
!architectureUnavailable.value &&
|
||||
!helperUnavailable.value &&
|
||||
!environmentStatus.value.kvmExist,
|
||||
);
|
||||
const installableDependencyItems = computed<DependencyItem[]>(() => [
|
||||
{
|
||||
name: 'libvirt',
|
||||
ready: environmentStatus.value.libvirtActive,
|
||||
exist: environmentStatus.value.libvirtExist || environmentStatus.value.libvirtActive,
|
||||
message: environmentStatus.value.libvirtMessage,
|
||||
},
|
||||
{
|
||||
name: 'storage',
|
||||
ready: environmentStatus.value.storageExist,
|
||||
exist: environmentStatus.value.storageExist,
|
||||
message: environmentStatus.value.storageMessage,
|
||||
},
|
||||
]);
|
||||
const installableDependenciesIncomplete = computed(() => installableDependencyItems.value.some((item) => !item.ready));
|
||||
const scriptInstallDependencyItems = computed(() =>
|
||||
installableDependencyItems.value.filter((item) => !item.ready && !item.exist),
|
||||
);
|
||||
const scriptInstallDependencyNames = computed(() =>
|
||||
scriptInstallDependencyItems.value.map((item) => item.name).join(', '),
|
||||
);
|
||||
const environmentBlocker = computed<EnvironmentBlocker>(() => {
|
||||
if (!environmentLoaded.value) {
|
||||
return '';
|
||||
}
|
||||
if (architectureUnavailable.value) {
|
||||
return 'architecture';
|
||||
}
|
||||
if (helperUnavailable.value) {
|
||||
return 'helper';
|
||||
}
|
||||
if (kvmUnavailable.value) {
|
||||
return 'kvm';
|
||||
}
|
||||
if (installableDependenciesIncomplete.value) {
|
||||
return 'dependency';
|
||||
}
|
||||
return '';
|
||||
});
|
||||
const hasEnvironmentDetails = computed(
|
||||
() => environmentLoaded.value && (!!environmentBlocker.value || !!installableDependencyItems.value.length),
|
||||
);
|
||||
const environmentReady = computed(() => environmentLoaded.value && environmentStatus.value.ready);
|
||||
const vmUnavailable = computed(() => !environmentLoaded.value || !environmentReady.value || !!environmentBlocker.value);
|
||||
const maskMessage = computed(() => {
|
||||
switch (environmentBlocker.value) {
|
||||
case 'architecture':
|
||||
return i18n.global.t('xpack.vm.architectureUnsupported', [environmentStatus.value.arch]);
|
||||
case 'helper':
|
||||
return i18n.global.t('xpack.vm.helperUnavailable');
|
||||
case 'kvm':
|
||||
return i18n.global.t('xpack.vm.kvmUnavailable');
|
||||
case 'dependency':
|
||||
return i18n.global.t('xpack.vm.dependencyNotReady');
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
});
|
||||
|
||||
const dependencyPurpose = (item: DependencyItem) => {
|
||||
const key = item.name.toLowerCase();
|
||||
const purposeMap: Record<string, string> = {
|
||||
libvirt: 'xpack.vm.dependencyPurposeMap.libvirt',
|
||||
storage: 'xpack.vm.dependencyPurposeMap.storage',
|
||||
};
|
||||
const i18nKey = purposeMap[key];
|
||||
return i18nKey ? i18n.global.t(i18nKey) : '-';
|
||||
};
|
||||
|
||||
const dependencyMessage = (item: DependencyItem) => {
|
||||
if (item.message) {
|
||||
return item.message;
|
||||
}
|
||||
if (!item.exist) {
|
||||
return i18n.global.t('cronjob.library.noSuchApp', [item.name]);
|
||||
}
|
||||
return '-';
|
||||
};
|
||||
|
||||
const canEnableDependency = (item: DependencyItem) => {
|
||||
return item.name === 'libvirt' && item.exist && !item.ready;
|
||||
};
|
||||
|
||||
const enableEnvironment = async () => {
|
||||
environmentEnabling.value = true;
|
||||
try {
|
||||
await enableVMEnvironment();
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
await loadEnvironment();
|
||||
} finally {
|
||||
environmentEnabling.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goScriptLibrary = () => {
|
||||
dependencyVisible.value = false;
|
||||
routerToNameWithQuery('Library', { uncached: 'true' });
|
||||
};
|
||||
|
||||
const loadEnvironment = async () => {
|
||||
loading.value = true;
|
||||
try {
|
||||
const res = await checkVMEnvironment();
|
||||
environmentStatus.value = res.data || defaultEnvironment();
|
||||
} catch {
|
||||
environmentStatus.value = defaultEnvironment();
|
||||
} finally {
|
||||
environmentLoaded.value = true;
|
||||
dependencyVisible.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(loadEnvironment);
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-environment-check {
|
||||
position: relative;
|
||||
min-height: 240px;
|
||||
}
|
||||
|
||||
.vm-environment-prompt {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.vm-dependency-warning {
|
||||
margin-right: 4px;
|
||||
color: var(--el-color-warning);
|
||||
vertical-align: -1px;
|
||||
}
|
||||
|
||||
.vm-environment-helper {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vm-script-library-helper {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.vm-script-library-helper__title {
|
||||
display: inline;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.vm-script-library-helper__link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: 8px;
|
||||
line-height: 18px;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vm-kvm-helper__list {
|
||||
margin: 6px 0 0 0;
|
||||
padding-left: 18px;
|
||||
line-height: 22px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,119 @@
|
||||
<template>
|
||||
<DrawerPro
|
||||
v-model="visible"
|
||||
:header="$t('commons.button.create')"
|
||||
size="large"
|
||||
:auto-close="!loading"
|
||||
@close="resetForm"
|
||||
>
|
||||
<el-form ref="formRef" label-position="top" :model="form" :rules="rules">
|
||||
<el-form-item :label="$t('commons.table.name')" prop="name">
|
||||
<el-input v-model.trim="form.name" />
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.type')" prop="type">
|
||||
<el-tag type="primary">{{ $t('xpack.vm.dir') }}</el-tag>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('xpack.vm.path')" prop="path">
|
||||
<el-input v-model.trim="form.path">
|
||||
<template #append>
|
||||
<el-button icon="Folder" @click="pathRef.acceptParams({ dir: true, path: form.path || '/' })" />
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-checkbox v-model="form.autoStart">{{ $t('xpack.vm.resourceAutoStart') }}</el-checkbox>
|
||||
</el-form-item>
|
||||
<el-form-item :label="$t('commons.table.description')" prop="description">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button :disabled="loading" @click="visible = false">{{ $t('commons.button.cancel') }}</el-button>
|
||||
<el-button v-permission type="primary" :disabled="loading" @click="submit">
|
||||
{{ $t('commons.button.confirm') }}
|
||||
</el-button>
|
||||
</template>
|
||||
</DrawerPro>
|
||||
<div v-if="visible && loading" v-loading="loading" class="vm-drawer-loading-mask"></div>
|
||||
<FileList ref="pathRef" @choose="loadPath" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue';
|
||||
import { type FormInstance, type FormRules } from 'element-plus';
|
||||
import FileList from '@/components/file-list/index.vue';
|
||||
import i18n from '@/lang';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { createVMStorage } from '@/api/modules/vm';
|
||||
import { Rules } from '@/global/form-rules';
|
||||
|
||||
const emit = defineEmits(['search']);
|
||||
const visible = ref(false);
|
||||
const loading = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const pathRef = ref();
|
||||
|
||||
const form = reactive<VM.StorageCreate & { id: number }>({
|
||||
id: 0,
|
||||
name: '',
|
||||
type: 'dir',
|
||||
path: '',
|
||||
autoStart: true,
|
||||
description: '',
|
||||
});
|
||||
|
||||
const rules = reactive<FormRules>({
|
||||
name: [Rules.requiredInput, Rules.vmNetwork],
|
||||
path: [Rules.requiredInput],
|
||||
});
|
||||
|
||||
const acceptParams = () => {
|
||||
resetForm();
|
||||
visible.value = true;
|
||||
};
|
||||
|
||||
const loadPath = async (path: string) => {
|
||||
form.path = path;
|
||||
await formRef.value?.validateField('path');
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!formRef.value) return;
|
||||
await formRef.value.validate();
|
||||
loading.value = true;
|
||||
await createVMStorage({ ...form, path: form.path.trim() })
|
||||
.then(() => {
|
||||
visible.value = false;
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
})
|
||||
.finally(() => {
|
||||
emit('search');
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
form.id = 0;
|
||||
form.name = '';
|
||||
form.type = 'dir';
|
||||
form.path = '';
|
||||
form.autoStart = true;
|
||||
form.description = '';
|
||||
formRef.value?.clearValidate();
|
||||
};
|
||||
|
||||
defineExpose({ acceptParams });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.vm-drawer-loading-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 3000;
|
||||
width: 50%;
|
||||
background: var(--el-mask-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<LayoutContent :title="$t('xpack.vm.storagePool')">
|
||||
<template #leftToolBar>
|
||||
<el-button v-permission type="primary" :disabled="vmUnavailable" @click="openCreate">
|
||||
{{ $t('commons.button.create') }}
|
||||
</el-button>
|
||||
</template>
|
||||
<template #rightToolBar>
|
||||
<TableSearch v-model:searchName="searchName" @search="search" />
|
||||
<TableRefresh @search="search" />
|
||||
</template>
|
||||
<template #main>
|
||||
<div class="storage-card-list" v-if="items.length" v-loading="loading">
|
||||
<article v-for="item in items" :key="item.name" class="storage-card">
|
||||
<div class="storage-card__header">
|
||||
<div class="storage-card__title">
|
||||
<span class="storage-card__name" :title="item.name">{{ item.name }}</span>
|
||||
<el-tag round size="small" effect="plain">
|
||||
{{ item.type === 'dir' ? $t('xpack.vm.dir') : '-' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-dropdown v-permission placement="bottom" :disabled="loading || !canToggleStatus(item)">
|
||||
<span class="storage-card__status">
|
||||
<Status
|
||||
:status="item.status || RESOURCE_STATUS_UNKNOWN"
|
||||
:msg="item.error"
|
||||
:operate="canToggleStatus(item)"
|
||||
:disabled="statusSwitchingIds.includes(item.id)"
|
||||
/>
|
||||
</span>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
:disabled="
|
||||
statusSwitchingIds.includes(item.id) ||
|
||||
normalizeStatus(item.status) === 'running'
|
||||
"
|
||||
@click="changeStatus(item, 'running')"
|
||||
>
|
||||
{{ $t('commons.operate.start') }}
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item
|
||||
:disabled="
|
||||
statusSwitchingIds.includes(item.id) ||
|
||||
normalizeStatus(item.status) === 'inactive'
|
||||
"
|
||||
@click="changeStatus(item, 'inactive')"
|
||||
>
|
||||
{{ $t('commons.operate.stop') }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
</div>
|
||||
<div class="storage-card__sub" :title="item.path">
|
||||
<span>{{ item.path || '-' }}</span>
|
||||
</div>
|
||||
<div class="storage-card__meta">
|
||||
<div class="storage-card__meta-item">
|
||||
<span class="storage-card__meta-label">{{ $t('xpack.vm.resourceAutoStart') }}</span>
|
||||
<el-switch
|
||||
v-permission
|
||||
size="small"
|
||||
v-model="item.autoStart"
|
||||
:disabled="!canToggleStatus(item)"
|
||||
:loading="switchingIds.includes(item.id)"
|
||||
@change="changeAutoStart(item, $event)"
|
||||
/>
|
||||
</div>
|
||||
<div class="storage-card__meta-item">
|
||||
<span class="storage-card__meta-label">{{ $t('xpack.vm.vmCount') }}</span>
|
||||
<el-dropdown
|
||||
v-if="item.vmCount"
|
||||
class="storage-vm-dropdown"
|
||||
placement="bottom"
|
||||
trigger="click"
|
||||
@command="goVMList"
|
||||
>
|
||||
<el-button class="storage-card__meta-link" link type="primary">
|
||||
{{ item.vmCount || 0 }}
|
||||
</el-button>
|
||||
<template #dropdown>
|
||||
<el-dropdown-menu>
|
||||
<el-dropdown-item
|
||||
v-for="vm in item.vms || []"
|
||||
:key="vm.id"
|
||||
:command="vm.name"
|
||||
class="storage-vm-dropdown-item"
|
||||
>
|
||||
{{ vm.name }}
|
||||
</el-dropdown-item>
|
||||
</el-dropdown-menu>
|
||||
</template>
|
||||
</el-dropdown>
|
||||
<span v-else class="storage-card__meta-value">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-card__detail">
|
||||
<div class="storage-card__detail-item">
|
||||
<span>{{ $t('xpack.vm.capacity') }}</span>
|
||||
<strong>{{ item.capacity ? computeSize(item.capacity) : '-' }}</strong>
|
||||
</div>
|
||||
<div class="storage-card__detail-item">
|
||||
<span>{{ $t('xpack.vm.available') }}</span>
|
||||
<strong>{{ item.available ? computeSize(item.available) : '-' }}</strong>
|
||||
</div>
|
||||
<div class="storage-card__detail-item storage-card__editable">
|
||||
<span>{{ $t('commons.table.description') }}</span>
|
||||
<strong :title="item.description">{{ item.description || '-' }}</strong>
|
||||
<el-button v-permission type="primary" link icon="EditPen" @click="openInlineEdit(item)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="storage-card__actions">
|
||||
<el-button v-permission link type="primary" @click="openDelete(item)">
|
||||
{{ $t('commons.button.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<el-empty v-else :description="$t('commons.msg.noneData')" />
|
||||
</template>
|
||||
</LayoutContent>
|
||||
<Operate ref="operateRef" @search="search" />
|
||||
<ResourceDelete ref="deleteRef" resource-type="storagePool" @deleted="search" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
import { computeSize } from '@/utils/size';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { listVMStorages, updateVMStorage } from '@/api/modules/vm';
|
||||
import Operate from '@/views/vm/storage/create/index.vue';
|
||||
import ResourceDelete from '@/views/vm/components/resource-delete.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
vmUnavailable: boolean;
|
||||
vmEnvironmentLoaded: boolean;
|
||||
}>();
|
||||
const loading = ref(false);
|
||||
const searchName = ref('');
|
||||
const items = ref<VM.StorageItem[]>([]);
|
||||
const switchingIds = ref<number[]>([]);
|
||||
const statusSwitchingIds = ref<number[]>([]);
|
||||
const operateRef = ref();
|
||||
const deleteRef = ref<InstanceType<typeof ResourceDelete>>();
|
||||
const vmUnavailable = computed(() => props.vmUnavailable);
|
||||
const router = useRouter();
|
||||
const RESOURCE_STATUS_RUNNING = 'running';
|
||||
const RESOURCE_STATUS_INACTIVE = 'inactive';
|
||||
const RESOURCE_STATUS_DELETED = 'deleted';
|
||||
const RESOURCE_STATUS_UNKNOWN = 'unknown';
|
||||
|
||||
const search = async () => {
|
||||
if (!props.vmEnvironmentLoaded || vmUnavailable.value) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await listVMStorages()
|
||||
.then((res) => {
|
||||
const keyword = searchName.value.trim().toLowerCase();
|
||||
items.value = keyword
|
||||
? (res.data || []).filter((item) => item.name.toLowerCase().includes(keyword))
|
||||
: res.data || [];
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
if (vmUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
operateRef.value?.acceptParams();
|
||||
};
|
||||
|
||||
const goVMList = (name: string) => {
|
||||
router.push({ path: '/vm/list', query: { name } });
|
||||
};
|
||||
|
||||
const changeAutoStart = async (row: VM.StorageItem, value: string | number | boolean) => {
|
||||
if (!canToggleStatus(row)) {
|
||||
row.autoStart = !Boolean(value);
|
||||
return;
|
||||
}
|
||||
switchingIds.value.push(row.id);
|
||||
const nextValue = Boolean(value);
|
||||
loading.value = true;
|
||||
await updateVMStorage({ id: row.id, autoStart: nextValue })
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
})
|
||||
.catch((error) => {
|
||||
row.autoStart = !nextValue;
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
switchingIds.value = switchingIds.value.filter((id) => id !== row.id);
|
||||
});
|
||||
};
|
||||
|
||||
const canToggleStatus = (row: VM.StorageItem) => {
|
||||
return (
|
||||
resourceExists(row.status) &&
|
||||
[RESOURCE_STATUS_RUNNING, RESOURCE_STATUS_INACTIVE].includes(normalizeStatus(row.status))
|
||||
);
|
||||
};
|
||||
|
||||
const normalizeStatus = (status: string) => status?.toLowerCase() || '';
|
||||
|
||||
const resourceExists = (status: string) => normalizeStatus(status) !== RESOURCE_STATUS_DELETED;
|
||||
|
||||
const changeStatus = async (row: VM.StorageItem, status: 'running' | 'inactive') => {
|
||||
if (!canToggleStatus(row) || normalizeStatus(row.status) === status) {
|
||||
return;
|
||||
}
|
||||
const oldStatus = row.status;
|
||||
row.status = status;
|
||||
statusSwitchingIds.value.push(row.id);
|
||||
loading.value = true;
|
||||
await updateVMStorage({ id: row.id, status })
|
||||
.then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
})
|
||||
.catch((error) => {
|
||||
row.status = oldStatus;
|
||||
throw error;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
statusSwitchingIds.value = statusSwitchingIds.value.filter((id) => id !== row.id);
|
||||
});
|
||||
};
|
||||
|
||||
const openInlineEdit = async (row: VM.StorageItem) => {
|
||||
let res;
|
||||
try {
|
||||
res = await ElMessageBox.prompt(
|
||||
i18n.global.t('commons.table.description'),
|
||||
i18n.global.t('commons.button.edit'),
|
||||
{
|
||||
inputValue: row.description || '',
|
||||
inputType: 'textarea',
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const value = String(res.value || '');
|
||||
await updateVMStorage({ id: row.id, description: value }).then(() => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
search();
|
||||
});
|
||||
};
|
||||
|
||||
const openDelete = (row: VM.StorageItem) => {
|
||||
deleteRef.value?.open(row);
|
||||
};
|
||||
|
||||
watch(() => [props.vmEnvironmentLoaded, props.vmUnavailable], search, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.storage-card-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(360px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.storage-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 240px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--el-card-border-color, var(--el-border-color-lighter));
|
||||
border-radius: 8px;
|
||||
background: var(--el-card-bg-color, var(--el-fill-color-blank));
|
||||
}
|
||||
|
||||
.storage-card__header,
|
||||
.storage-card__title,
|
||||
.storage-card__meta-item,
|
||||
.storage-card__detail-item,
|
||||
.storage-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.storage-card__header {
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.storage-card__title {
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.storage-card__status {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.storage-card__name {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storage-card__sub {
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
min-height: 22px;
|
||||
margin-top: 8px;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.storage-card__meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.storage-card__meta-item {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.storage-card__detail-item span {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.storage-card__meta-label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.storage-card__meta-value {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storage-card__meta-value.is-enabled {
|
||||
color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.storage-card__meta-link {
|
||||
height: auto;
|
||||
padding: 0;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.storage-vm-dropdown {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
:global(.storage-vm-dropdown-item) {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
:global(.storage-vm-dropdown-item span) {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storage-card__meta-item :deep(.el-switch) {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.storage-card__detail {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.storage-card__detail-item {
|
||||
min-width: 0;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.storage-card__detail-item strong {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.storage-card__editable {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.storage-card__editable .el-button {
|
||||
flex: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.storage-card__sub.storage-card__editable span,
|
||||
.storage-card__detail-item.storage-card__editable strong {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.storage-card__sub.storage-card__editable span {
|
||||
flex: initial;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.storage-card__detail-item.storage-card__editable strong {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.storage-card__detail-item.storage-card__editable > span {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.storage-card__actions {
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.storage-card__actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.storage-card-list {
|
||||
grid-template-columns: repeat(2, minmax(360px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.storage-card-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<LayoutContent :title="$t('xpack.vm.template')">
|
||||
<template #rightToolBar>
|
||||
<TableSearch v-model:searchName="searchName" @search="search" />
|
||||
<TableRefresh @search="search" />
|
||||
</template>
|
||||
<template #main>
|
||||
<div v-loading="loading" class="template-card-content">
|
||||
<div v-if="filteredItems.length" class="template-card-grid">
|
||||
<el-card v-for="item in filteredItems" :key="item.id" class="template-card" shadow="hover">
|
||||
<div class="template-card-header">
|
||||
<div class="template-card-title">
|
||||
<span class="template-card-name" :title="item.name">{{ item.name }}</span>
|
||||
</div>
|
||||
<el-tag :type="item.isExist ? 'success' : 'danger'" round size="small" effect="plain">
|
||||
{{ item.isExist ? $t('commons.status.ready') : $t('commons.status.deleted') }}
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div class="template-card-resource">
|
||||
<div class="template-card-resource-item">
|
||||
<span>{{ $t('xpack.vm.cpu') }}</span>
|
||||
<strong>{{ item.cpu }} {{ $t('commons.units.core') }}</strong>
|
||||
</div>
|
||||
<div class="template-card-resource-item">
|
||||
<span>{{ $t('xpack.vm.memory') }}</span>
|
||||
<strong>{{ computeSizeFromMB(item.memory) }}</strong>
|
||||
</div>
|
||||
<div class="template-card-resource-item">
|
||||
<span>{{ $t('xpack.vm.disk') }}</span>
|
||||
<strong>{{ formatDiskSize(item.diskSize) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="template-card-info">
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.sourceVM') }}</span>
|
||||
<strong>{{ item.sourceVM || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.diskBus') }}</span>
|
||||
<strong>{{ item.diskBus?.toUpperCase() || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.storagePool') }}</span>
|
||||
<strong>{{ item.storagePool || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.diskPath') }}</span>
|
||||
<strong :title="item.diskPath">{{ item.diskPath || '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('xpack.vm.templateSize') }}</span>
|
||||
<strong>{{ item.isExist ? computeSize(item.size) : '-' }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('commons.table.date') }}</span>
|
||||
<strong>{{ formatDate(item.createdAt) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{ $t('commons.table.description') }}</span>
|
||||
<strong :title="item.description">{{ item.description || '-' }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="template-card-actions">
|
||||
<el-button
|
||||
v-permission
|
||||
link
|
||||
type="primary"
|
||||
:disabled="vmUnavailable"
|
||||
:loading="deleteLoading"
|
||||
@click="openDelete(item)"
|
||||
>
|
||||
{{ $t('commons.button.delete') }}
|
||||
</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
<el-empty v-else-if="!loading" :description="$t('commons.msg.noneData')" />
|
||||
</div>
|
||||
</template>
|
||||
</LayoutContent>
|
||||
|
||||
<TaskLog ref="taskLogRef" width="70%" @close="search" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import i18n from '@/lang';
|
||||
import TaskLog from '@/components/log/task/index.vue';
|
||||
import { VM } from '@/api/interface/vm';
|
||||
import { deleteVMTemplate, listVMTemplates } from '@/api/modules/vm';
|
||||
import { computeSize, computeSizeFromMB } from '@/utils/size';
|
||||
import { dateFormat } from '@/utils/date';
|
||||
import { newUUID } from '@/utils/id';
|
||||
import { MsgSuccess } from '@/utils/message';
|
||||
|
||||
const props = defineProps<{
|
||||
vmUnavailable: boolean;
|
||||
vmEnvironmentLoaded: boolean;
|
||||
}>();
|
||||
const vmUnavailable = computed(() => props.vmUnavailable);
|
||||
const loading = ref(false);
|
||||
const deleteLoading = ref(false);
|
||||
const searchName = ref('');
|
||||
const items = ref<VM.TemplateItem[]>([]);
|
||||
const taskLogRef = ref();
|
||||
|
||||
const filteredItems = computed(() => {
|
||||
const keyword = searchName.value.trim().toLowerCase();
|
||||
if (!keyword) {
|
||||
return items.value;
|
||||
}
|
||||
return items.value.filter((item) =>
|
||||
[item.name, item.sourceVM, item.diskPath, item.storagePool, item.description]
|
||||
.filter(Boolean)
|
||||
.some((value) => value.toLowerCase().includes(keyword)),
|
||||
);
|
||||
});
|
||||
|
||||
const search = async () => {
|
||||
if (!props.vmEnvironmentLoaded || vmUnavailable.value) {
|
||||
items.value = [];
|
||||
return;
|
||||
}
|
||||
loading.value = true;
|
||||
await listVMTemplates()
|
||||
.then((res) => {
|
||||
items.value = res.data || [];
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const openDelete = async (row: VM.TemplateItem) => {
|
||||
if (vmUnavailable.value) {
|
||||
return;
|
||||
}
|
||||
const templateName = row.diskPath ? `${row.name}(${row.diskPath})` : row.name;
|
||||
const confirmMessage = i18n.global.t('xpack.vm.deleteTemplateConfirm', [templateName]);
|
||||
try {
|
||||
await ElMessageBox.confirm(confirmMessage, i18n.global.t('commons.button.delete'), {
|
||||
confirmButtonText: i18n.global.t('commons.button.confirm'),
|
||||
cancelButtonText: i18n.global.t('commons.button.cancel'),
|
||||
type: 'warning',
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const taskID = newUUID();
|
||||
deleteLoading.value = true;
|
||||
await deleteVMTemplate({ taskID, id: row.id, deleteDisk: row.isExist })
|
||||
.then((res) => {
|
||||
MsgSuccess(i18n.global.t('commons.msg.operationSuccess'));
|
||||
taskLogRef.value?.openWithTaskID(res.data?.taskID || taskID, true);
|
||||
})
|
||||
.finally(() => {
|
||||
deleteLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const formatDiskSize = (size: number) => {
|
||||
if (!size) {
|
||||
return '-';
|
||||
}
|
||||
return `${size} GiB`;
|
||||
};
|
||||
|
||||
const formatDate = (value: string) => (value ? dateFormat(0, 0, value) : '-');
|
||||
|
||||
watch(() => [props.vmEnvironmentLoaded, props.vmUnavailable], search, { immediate: true });
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.template-card-content {
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.template-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.template-card {
|
||||
min-width: 0;
|
||||
|
||||
:deep(.el-card__body) {
|
||||
display: flex;
|
||||
min-height: 248px;
|
||||
flex-direction: column;
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.template-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.template-card-title {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-card-name {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 22px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-card-resource {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.template-card-resource-item {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--el-fill-color-extra-light);
|
||||
}
|
||||
|
||||
.template-card-resource-item span,
|
||||
.template-card-info span {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.template-card-resource-item strong,
|
||||
.template-card-info strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.template-card-info {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 14px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.template-card-info > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.template-card-info span {
|
||||
flex: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.template-card-info strong {
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.template-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.template-card-actions :deep(.el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.template-card-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.template-card-grid,
|
||||
.template-card-resource {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.template-card-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user