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:
ssongliu
2026-11-02 18:22:58 +08:00
committed by GitHub
parent f8c61993c5
commit c06c4e338b
94 changed files with 20756 additions and 109 deletions
+2
View File
@@ -79,3 +79,5 @@ opencode.json
superpowers
.worktrees/
.codex/
/agent/utils/vm/helper/bin/1panel-kvm*
+5 -2
View File
@@ -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)
+1
View File
@@ -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
+447
View File
@@ -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"`
}
+81
View File
@@ -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"`
}
+315
View File
@@ -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
}
+1
View File
@@ -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()
+171
View File
@@ -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, `/\\`)
}
+464
View File
@@ -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
}
+36
View File
@@ -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
}
+111
View File
@@ -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()
}
+55
View File
@@ -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)
}
+403
View File
@@ -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
+23 -23
View File
@@ -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"
],
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -737,7 +737,7 @@ ErrSSHKeyMismatch: "کلید عمومی و خصوصی SSH مطابقت ندار
# ماشین مجازی
ErrVMNotFound: "ماشین مجازی وجود ندارد"
ErrVMAlreadyExists: "ماشین مجازی از قبل وجود دارد"
ErrVMCountLimit: "در نسخه Professional می‌توانید حداکثر {{ .limit }} ماشین مجازی ایجاد کنید. یک ماشین مجازی موجود را حذف کنید یا به نسخه Enterprise ارتقا دهید و دوباره تلاش کنید."
ErrVMCountLimit: "در نسخه فعلی می‌توانید حداکثر {{ .limit }} ماشین مجازی ایجاد کنید. یک ماشین مجازی موجود را حذف کنید یا به نسخه بالاتر ارتقا دهید و دوباره تلاش کنید."
ErrVMImageAlreadyExists: "فایل ایمیج ماشین مجازی از قبل وجود دارد: {{ .detail }}"
ErrVMStoragePathInUse: "مسیر استخر ذخیره‌سازی قبلاً توسط [{{ .name }}] استفاده شده است"
ErrVMHelperNotFound: "1panel-kvm یافت نشد"
+1 -1
View File
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 公開鍵と秘密鍵が一致しません"
# 仮想マシン
ErrVMNotFound: "仮想マシンが存在しません"
ErrVMAlreadyExists: "仮想マシンは既に存在します"
ErrVMCountLimit: "Professional 版では最大 {{ .limit }} 台の仮想マシンを作成できます。既存の仮想マシンを削除するか、Enterprise 版にアップグレードしてから再試行してください。"
ErrVMCountLimit: "現在のエディションでは最大 {{ .limit }} 台の仮想マシンを作成できます。 既存の仮想マシンを削除するか、上位エディションにアップグレードしてから再試行してください。"
ErrVMImageAlreadyExists: "仮想マシンのイメージファイルは既に存在します: {{ .detail }}"
ErrVMStoragePathInUse: "ストレージプールのパスは既に [{{ .name }}] で使用されています"
ErrVMHelperNotFound: "1panel-kvm が見つかりません"
+1 -1
View File
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 공개 키와 개인 키가 일치하지 않습니다"
# 가상 머신
ErrVMNotFound: "가상 머신이 존재하지 않습니다"
ErrVMAlreadyExists: "가상 머신이 이미 존재합니다"
ErrVMCountLimit: "Professional 버전에서는 가상 머신을 최대 {{ .limit }}대까지 생성할 수 있습니다. 기존 가상 머신을 삭제하거나 Enterprise 버전으로 업그레이드한 후 다시 시도하세요."
ErrVMCountLimit: "현재 버전에서는 가상 머신을 최대 {{ .limit }}대까지 생성할 수 있습니다. 기존 가상 머신을 삭제하거나 상위 버전으로 업그레이드한 후 다시 시도하세요."
ErrVMImageAlreadyExists: "가상 머신 이미지 파일이 이미 존재합니다: {{ .detail }}"
ErrVMStoragePathInUse: "스토리지 풀 경로가 이미 [{{ .name }}]에서 사용 중입니다"
ErrVMHelperNotFound: "1panel-kvm을 찾을 수 없습니다"
+1 -1
View File
@@ -727,7 +727,7 @@ ErrSSHKeyMismatch: "ຄີສາທາລະນະ ແລະ ຄີສ່ວນ
# virtual machine
ErrVMNotFound: "ບໍ່ມີເຄື່ອງສະເໝືອນ"
ErrVMAlreadyExists: "ມີເຄື່ອງສະເໝືອນຢູ່ແລ້ວ"
ErrVMCountLimit: "ລຸ້ນ Professional ສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {{ .limit }} ເຄື່ອງ. ກະລຸນາລຶບເຄື່ອງສະເໝືອນທີ່ມີຢູ່ ຫຼື ອັບເກຣດເປັນລຸ້ນ Enterprise ແລ້ວລອງອີກຄັ້ງ."
ErrVMCountLimit: "ລຸ້ນປັດຈຸບັນສາມາດສ້າງເຄື່ອງສະເໝືອນໄດ້ສູງສຸດ {{ .limit }} ເຄື່ອງ. ກະລຸນາລຶບເຄື່ອງສະເໝືອນທີ່ມີຢູ່ ຫຼື ອັບເກຣດລຸ້ນ ແລ້ວລອງອີກຄັ້ງ."
ErrVMImageAlreadyExists: "ມີອິມເມຈເຄື່ອງສະເໝືອນຢູ່ແລ້ວ: {{ .detail }}"
ErrVMStoragePathInUse: "ເສັ້ນທາງພູນຈັດເກັບຖືກໃຊ້ໂດຍ [{{ .name }}] ແລ້ວ"
ErrVMHelperNotFound: "ບໍ່ພົບ 1panel-kvm"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "Открытый и закрытый ключи SSH не с
# виртуальная машина
ErrVMNotFound: "Виртуальная машина не существует"
ErrVMAlreadyExists: "Виртуальная машина уже существует"
ErrVMCountLimit: "В редакции Professional можно создать не более {{ .limit }} виртуальных машин. Удалите существующую виртуальную машину или перейдите на Enterprise и повторите попытку."
ErrVMCountLimit: "В текущей редакции можно создать не более {{ .limit }} виртуальных машин. Удалите существующую виртуальную машину или перейдите на старшую редакцию и повторите попытку."
ErrVMImageAlreadyExists: "Файл образа виртуальной машины уже существует: {{ .detail }}"
ErrVMStoragePathInUse: "Путь пула хранения уже используется [{{ .name }}]"
ErrVMHelperNotFound: "1panel-kvm не найден"
+1 -1
View File
@@ -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ı"
+1 -1
View File
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 公鑰與私鑰不相符"
# 虛擬機
ErrVMNotFound: "虛擬機不存在"
ErrVMAlreadyExists: "虛擬機已存在"
ErrVMCountLimit: "專業版最多可建立 {{ .limit }} 台虛擬機,請刪除現有虛擬機或升級企業版後重試。"
ErrVMCountLimit: "目前版本最多可建立 {{ .limit }} 台虛擬機。 請刪除現有虛擬機或升級版本後重試。"
ErrVMImageAlreadyExists: "虛擬機映像檔已存在: {{ .detail }}"
ErrVMStoragePathInUse: "儲存池路徑已被 [{{ .name }}] 使用"
ErrVMHelperNotFound: "1panel-kvm 不存在"
+1 -1
View File
@@ -736,7 +736,7 @@ ErrSSHKeyMismatch: "SSH 公钥与私钥不匹配"
# 虚拟机
ErrVMNotFound: "虚拟机不存在"
ErrVMAlreadyExists: "虚拟机已存在"
ErrVMCountLimit: "专业版最多可创建 {{ .limit }} 台虚拟机,请删除已有虚拟机或升级企业版后重试。"
ErrVMCountLimit: "当前版本最多可创建 {{ .limit }} 台虚拟机。 请删除已有虚拟机或升级版本后重试。"
ErrVMImageAlreadyExists: "虚拟机镜像文件已存在: {{ .detail }}"
ErrVMStoragePathInUse: "存储池路径已被 [{{ .name }}] 使用"
ErrVMHelperNotFound: "1panel-kvm 不存在"
+18
View File
@@ -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)
}
}
+1
View File
@@ -122,6 +122,7 @@ func agentDBMigrations() []*gormigrate.Migration {
migrations.AddContainerImageCheckUpdates,
migrations.AddModelQuickJump,
migrations.MigrateModelDownloader,
migrations.MigrateVMDatabase,
}
}
+107
View File
@@ -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
}
+1
View File
@@ -2,6 +2,7 @@ package router
func commonGroups() []CommonRouter {
return []CommonRouter{
&VMRouter{},
&DashboardRouter{},
&HostRouter{},
&ContainerRouter{},
+59
View File
@@ -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)
}
}
+2
View File
@@ -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")
+58
View File
@@ -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)
}
+12
View File
@@ -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
}
+12
View File
@@ -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
}
+20
View File
@@ -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
+549
View File
@@ -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
}
+334
View File
@@ -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}}
}
+45
View File
@@ -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
}
+46
View File
@@ -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()
}
+8 -1
View File
@@ -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
+23 -23
View File
@@ -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"
],
+7 -7
View File
@@ -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{
+1 -1
View File
@@ -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},
+1
View File
@@ -73,5 +73,6 @@ func coreMigrations() []*gormigrate.Migration {
migrations.UpdateAiMenuOrderV2,
migrations.AddPanelMCP,
migrations.AddAPIKeyDeletedAt,
migrations.MoveVirtualMachineMenuToCommunity,
}
}
+42
View File
@@ -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
})
},
}
+5
View File
@@ -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
+5 -5
View File
@@ -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 {
+8 -1
View File
@@ -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
}
+400
View File
@@ -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;
}
}
+168
View File
@@ -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);
};
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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: 'بررسی سلامت',
+1 -3
View File
@@ -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: 'ヘルスチェック',
+1 -3
View File
@@ -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: '상태 점검',
+1 -3
View File
@@ -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: 'ກວດສອບສຸຂະພາບ',
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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',
+1 -3
View File
@@ -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: 'Проверка состояния',
+1 -3
View File
@@ -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ü',
+1 -2
View File
@@ -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: '健康檢查',
+1 -2
View File
@@ -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: '健康检查',
+2
View File
@@ -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,
],
};
+89
View File
@@ -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;
+5
View File
@@ -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>
+418
View File
@@ -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>
+48
View File
@@ -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>
+873
View File
@@ -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>
+420
View File
@@ -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>
+102
View File
@@ -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>
+109
View File
@@ -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>
+156
View File
@@ -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>
+528
View File
@@ -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>
+676
View File
@@ -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>
+338
View File
@@ -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>
+478
View File
@@ -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>
+312
View File
@@ -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>