From 54b3b60efd7ebdec242e4ef8772c128e55ad628f Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Fri, 9 Jan 2026 03:22:35 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E7=A3=81?= =?UTF-8?q?=E7=9B=98=E7=AE=A1=E7=90=86=E5=B7=A5=E5=85=B7=E5=88=B0=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E7=AE=B1=20(#1195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial plan * 实现磁盘管理工具的后端和前端基础功能 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * 完成磁盘管理工具功能实现并验证构建成功 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * 添加输入验证防止命令注入攻击 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * 移除命令注入验证并修复评审意见 Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> * feat: merge main * feat: merge main * feat: 分区优化 * feat: fstab管理 --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: devhaozi <115467771+devhaozi@users.noreply.github.com> Co-authored-by: 耗子 --- cmd/ace/wire_gen.go | 3 +- internal/http/request/toolbox_disk.go | 76 ++ internal/route/http.go | 22 + internal/service/service.go | 1 + internal/service/toolbox_disk.go | 567 +++++++++ web/src/api/panel/toolbox-disk/index.ts | 46 + web/src/views/toolbox/DiskView.vue | 1020 +++++++++++++++++ web/src/views/toolbox/IndexView.vue | 7 +- .../toolbox/{SSHView.vue => SshView.vue} | 24 +- web/src/views/toolbox/WebHookView.vue | 2 +- 10 files changed, 1748 insertions(+), 20 deletions(-) create mode 100644 internal/http/request/toolbox_disk.go create mode 100644 internal/service/toolbox_disk.go create mode 100644 web/src/api/panel/toolbox-disk/index.ts create mode 100644 web/src/views/toolbox/DiskView.vue rename web/src/views/toolbox/{SSHView.vue => SshView.vue} (95%) diff --git a/cmd/ace/wire_gen.go b/cmd/ace/wire_gen.go index b83d2901..e9f378d7 100644 --- a/cmd/ace/wire_gen.go +++ b/cmd/ace/wire_gen.go @@ -119,6 +119,7 @@ func initWeb() (*app.Web, error) { toolboxSystemService := service.NewToolboxSystemService(locale) toolboxBenchmarkService := service.NewToolboxBenchmarkService(locale) toolboxSSHService := service.NewToolboxSSHService(locale) + toolboxDiskService := service.NewToolboxDiskService(locale) webHookRepo := data.NewWebHookRepo(locale, db) webHookService := service.NewWebHookService(webHookRepo) codeserverApp := codeserver.NewApp() @@ -142,7 +143,7 @@ func initWeb() (*app.Web, error) { s3fsApp := s3fs.NewApp(locale) supervisorApp := supervisor.NewApp(locale) loader := bootstrap.NewLoader(codeserverApp, dockerApp, fail2banApp, frpApp, giteaApp, mariadbApp, memcachedApp, minioApp, mysqlApp, nginxApp, openrestyApp, perconaApp, phpmyadminApp, podmanApp, postgresqlApp, pureftpdApp, redisApp, rsyncApp, s3fsApp, supervisorApp) - http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, webHookService, loader) + http := route.NewHttp(config, userService, userTokenService, homeService, taskService, websiteService, databaseService, databaseServerService, databaseUserService, backupService, certService, certDNSService, certAccountService, appService, environmentService, environmentPHPService, cronService, processService, safeService, firewallService, sshService, containerService, containerComposeService, containerNetworkService, containerImageService, containerVolumeService, fileService, monitorService, settingService, systemctlService, toolboxSystemService, toolboxBenchmarkService, toolboxSSHService, toolboxDiskService, webHookService, loader) wsService := service.NewWsService(locale, config, logger, sshRepo) ws := route.NewWs(wsService) mux, err := bootstrap.NewRouter(locale, middlewares, http, ws) diff --git a/internal/http/request/toolbox_disk.go b/internal/http/request/toolbox_disk.go new file mode 100644 index 00000000..be260a49 --- /dev/null +++ b/internal/http/request/toolbox_disk.go @@ -0,0 +1,76 @@ +package request + +// ToolboxDiskDevice 磁盘设备请求 +type ToolboxDiskDevice struct { + Device string `form:"device" json:"device" validate:"required"` +} + +// ToolboxDiskMount 挂载请求 +type ToolboxDiskMount struct { + Device string `form:"device" json:"device" validate:"required"` + Path string `form:"path" json:"path" validate:"required"` + WriteFstab bool `form:"write_fstab" json:"write_fstab"` + MountOption string `form:"mount_option" json:"mount_option"` +} + +// ToolboxDiskUmount 卸载请求 +type ToolboxDiskUmount struct { + Path string `form:"path" json:"path" validate:"required"` +} + +// ToolboxDiskFormat 格式化请求 +type ToolboxDiskFormat struct { + Device string `form:"device" json:"device" validate:"required"` + FsType string `form:"fs_type" json:"fs_type" validate:"required|in:ext4,ext3,xfs,btrfs"` +} + +// ToolboxDiskVG 卷组请求 +type ToolboxDiskVG struct { + Name string `form:"name" json:"name" validate:"required"` + Devices []string `form:"devices" json:"devices" validate:"required"` +} + +// ToolboxDiskLV 逻辑卷请求 +type ToolboxDiskLV struct { + Name string `form:"name" json:"name" validate:"required"` + VGName string `form:"vg_name" json:"vg_name" validate:"required"` + Size int `form:"size" json:"size" validate:"required|min:1"` +} + +// ToolboxDiskVGName 卷组名称请求 +type ToolboxDiskVGName struct { + Name string `form:"name" json:"name" validate:"required"` +} + +// ToolboxDiskLVPath 逻辑卷路径请求 +type ToolboxDiskLVPath struct { + Path string `form:"path" json:"path" validate:"required"` +} + +// ToolboxDiskExtendLV 扩容逻辑卷请求 +type ToolboxDiskExtendLV struct { + Path string `form:"path" json:"path" validate:"required"` + Size int `form:"size" json:"size" validate:"required|min:1"` + Resize bool `form:"resize" json:"resize"` +} + +// ToolboxDiskInit 初始化磁盘请求 +type ToolboxDiskInit struct { + Device string `form:"device" json:"device" validate:"required"` + FsType string `form:"fs_type" json:"fs_type" validate:"required|in:ext4,ext3,xfs,btrfs"` +} + +// ToolboxDiskFstabEntry fstab 条目结构 +type ToolboxDiskFstabEntry struct { + Device string `json:"device"` // 设备(UUID=xxx 或 /dev/xxx) + MountPoint string `json:"mount_point"` // 挂载点 + FsType string `json:"fs_type"` // 文件系统类型 + Options string `json:"options"` // 挂载选项 + Dump string `json:"dump"` // 备份标志 + Pass string `json:"pass"` // 检查顺序 +} + +// ToolboxDiskFstabDelete 删除 fstab 条目请求 +type ToolboxDiskFstabDelete struct { + MountPoint string `form:"mount_point" json:"mount_point" validate:"required"` +} diff --git a/internal/route/http.go b/internal/route/http.go index f6c4b643..77cb5c92 100644 --- a/internal/route/http.go +++ b/internal/route/http.go @@ -49,6 +49,7 @@ type Http struct { toolboxSystem *service.ToolboxSystemService toolboxBenchmark *service.ToolboxBenchmarkService toolboxSSH *service.ToolboxSSHService + toolboxDisk *service.ToolboxDiskService webhook *service.WebHookService apps *apploader.Loader } @@ -87,6 +88,7 @@ func NewHttp( toolboxSystem *service.ToolboxSystemService, toolboxBenchmark *service.ToolboxBenchmarkService, toolboxSSH *service.ToolboxSSHService, + toolboxDisk *service.ToolboxDiskService, webhook *service.WebHookService, apps *apploader.Loader, ) *Http { @@ -124,6 +126,7 @@ func NewHttp( toolboxSystem: toolboxSystem, toolboxBenchmark: toolboxBenchmark, toolboxSSH: toolboxSSH, + toolboxDisk: toolboxDisk, webhook: webhook, apps: apps, } @@ -463,6 +466,25 @@ func (route *Http) Register(r *chi.Mux) { r.Post("/root_key", route.toolboxSSH.GenerateRootKey) }) + r.Route("/toolbox_disk", func(r chi.Router) { + r.Get("/list", route.toolboxDisk.List) + r.Post("/partitions", route.toolboxDisk.GetPartitions) + r.Post("/mount", route.toolboxDisk.Mount) + r.Post("/umount", route.toolboxDisk.Umount) + r.Post("/format", route.toolboxDisk.Format) + r.Post("/init", route.toolboxDisk.Init) + r.Get("/fstab", route.toolboxDisk.GetFstab) + r.Delete("/fstab", route.toolboxDisk.DeleteFstab) + r.Get("/lvm", route.toolboxDisk.GetLVMInfo) + r.Post("/lvm/pv", route.toolboxDisk.CreatePV) + r.Delete("/lvm/pv", route.toolboxDisk.RemovePV) + r.Post("/lvm/vg", route.toolboxDisk.CreateVG) + r.Delete("/lvm/vg", route.toolboxDisk.RemoveVG) + r.Post("/lvm/lv", route.toolboxDisk.CreateLV) + r.Delete("/lvm/lv", route.toolboxDisk.RemoveLV) + r.Post("/lvm/lv/extend", route.toolboxDisk.ExtendLV) + }) + r.Route("/webhook", func(r chi.Router) { r.Get("/", route.webhook.List) r.Post("/", route.webhook.Create) diff --git a/internal/service/service.go b/internal/service/service.go index 756e74c9..065972ba 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -38,5 +38,6 @@ var ProviderSet = wire.NewSet( NewToolboxSystemService, NewToolboxBenchmarkService, NewToolboxSSHService, + NewToolboxDiskService, NewWsService, ) diff --git a/internal/service/toolbox_disk.go b/internal/service/toolbox_disk.go new file mode 100644 index 00000000..b9924464 --- /dev/null +++ b/internal/service/toolbox_disk.go @@ -0,0 +1,567 @@ +package service + +import ( + "encoding/json" + "fmt" + "net/http" + "regexp" + "strings" + + "github.com/leonelquinteros/gotext" + "github.com/libtnb/chix" + + "github.com/acepanel/panel/internal/http/request" + "github.com/acepanel/panel/pkg/shell" +) + +type ToolboxDiskService struct { + t *gotext.Locale +} + +func NewToolboxDiskService(t *gotext.Locale) *ToolboxDiskService { + return &ToolboxDiskService{ + t: t, + } +} + +// List 获取磁盘列表 +func (s *ToolboxDiskService) List(w http.ResponseWriter, r *http.Request) { + // 获取磁盘基本信息 + lsblkOutput, err := shell.Execf("lsblk -J -b -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,UUID,LABEL,MODEL") + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to get disk list: %v", err)) + return + } + + // 解析 lsblk JSON + var lsblkData struct { + BlockDevices []any `json:"blockdevices"` + } + if err = json.Unmarshal([]byte(lsblkOutput), &lsblkData); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to parse disk list: %v", err)) + return + } + + // 获取磁盘使用情况 + dfOutput, _ := shell.Execf("df -B1 --output=source,size,used,avail,pcent,target 2>/dev/null | tail -n +2") + + // 解析 df 输出为 map + dfMap := make(map[string]map[string]string) + lines := strings.Split(strings.TrimSpace(dfOutput), "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) >= 6 { + mountpoint := fields[5] + dfMap[mountpoint] = map[string]string{ + "size": fields[1], + "used": fields[2], + "avail": fields[3], + "percent": strings.TrimSuffix(fields[4], "%"), + } + } + } + + Success(w, chix.M{ + "disks": lsblkData.BlockDevices, + "df": dfMap, + }) +} + +// GetPartitions 获取分区列表 +func (s *ToolboxDiskService) GetPartitions(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskDevice](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + output, err := shell.Execf("lsblk -J -b -o NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,UUID,LABEL '/dev/%s'", req.Device) + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to get partitions: %v", err)) + return + } + + Success(w, output) +} + +// Mount 挂载分区 +func (s *ToolboxDiskService) Mount(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskMount](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if _, err = shell.Execf("test -d '%s' || mkdir -p '%s'", req.Path, req.Path); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to create mount point: %v", err)) + return + } + + if _, err = shell.Execf("mount '/dev/%s' '%s'", req.Device, req.Path); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to mount partition: %v", err)) + return + } + + // 如果需要写入 fstab + if req.WriteFstab { + // 获取分区的 UUID + uuid, err := shell.Execf("blkid -s UUID -o value '/dev/%s'", req.Device) + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to get partition UUID: %v", err)) + return + } + uuid = strings.TrimSpace(uuid) + if uuid == "" { + Error(w, http.StatusInternalServerError, s.t.Get("partition has no UUID")) + return + } + + // 获取文件系统类型 + fsType, err := shell.Execf("blkid -s TYPE -o value '/dev/%s'", req.Device) + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to get filesystem type: %v", err)) + return + } + fsType = strings.TrimSpace(fsType) + if fsType == "" { + fsType = "auto" + } + + // 挂载选项 + mountOption := req.MountOption + if mountOption == "" { + mountOption = "defaults" + } + + // 检查 fstab 中是否已存在该挂载点 + existCheck, _ := shell.Execf("grep -E '^[^#].*\\s+%s\\s+' /etc/fstab", req.Path) + if strings.TrimSpace(existCheck) != "" { + Error(w, http.StatusBadRequest, s.t.Get("mount point %s already exists in fstab", req.Path)) + return + } + + // 写入 fstab + fstabEntry := fmt.Sprintf("UUID=%s %s %s %s 0 2", uuid, req.Path, fsType, mountOption) + if _, err = shell.Execf("echo '%s' >> /etc/fstab", fstabEntry); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to write fstab: %v", err)) + return + } + } + + Success(w, nil) +} + +// Umount 卸载分区 +func (s *ToolboxDiskService) Umount(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskUmount](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if _, err = shell.Execf("umount '%s'", req.Path); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to umount partition: %v", err)) + return + } + + Success(w, nil) +} + +// Format 格式化分区 +func (s *ToolboxDiskService) Format(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskFormat](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + var formatCmd string + switch req.FsType { + case "ext4": + formatCmd = fmt.Sprintf("mkfs.ext4 -F '/dev/%s'", req.Device) + case "ext3": + formatCmd = fmt.Sprintf("mkfs.ext3 -F '/dev/%s'", req.Device) + case "xfs": + formatCmd = fmt.Sprintf("mkfs.xfs -f '/dev/%s'", req.Device) + case "btrfs": + formatCmd = fmt.Sprintf("mkfs.btrfs -f '/dev/%s'", req.Device) + default: + Error(w, http.StatusUnprocessableEntity, s.t.Get("unsupported filesystem type: %s", req.FsType)) + return + } + + if _, err = shell.Execf(formatCmd); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to format partition: %v", err)) + return + } + + Success(w, nil) +} + +// Init 初始化磁盘(删除所有分区,创建单个分区并格式化) +func (s *ToolboxDiskService) Init(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskInit](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + device := "/dev/" + req.Device + + // 检查设备是否存在 + if _, err = shell.Execf("test -b '%s'", device); err != nil { + Error(w, http.StatusBadRequest, s.t.Get("device not found: %s", device)) + return + } + + // 检查是否为系统盘(检查是否有分区挂载在 /) + mountInfo, _ := shell.Execf("lsblk -no MOUNTPOINT '%s' 2>/dev/null", device) + if strings.Contains(mountInfo, "/\n") || strings.TrimSpace(mountInfo) == "/" { + Error(w, http.StatusBadRequest, s.t.Get("cannot initialize system disk")) + return + } + + // 卸载该磁盘的所有分区 + _, _ = shell.Execf("umount '%s'* 2>/dev/null || true", device) + + // 先清除分区表 + if _, err = shell.Execf("wipefs -a '%s'", device); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to wipe disk: %v", err)) + return + } + + // sfdisk 创建 GPT 分区表和单个分区 + if _, err = shell.Execf("echo 'type=linux' | sfdisk '%s'", device); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to create partition: %v", err)) + return + } + + // 等待内核更新分区表 + _, _ = shell.Execf("partprobe '%s' 2>/dev/null || true", device) + _, _ = shell.Execf("sleep 1") + + // 确定新分区的设备名(device + "1",如 sdb1 或 nvme0n1p1) + var partDevice string + if strings.Contains(req.Device, "nvme") || strings.Contains(req.Device, "loop") { + partDevice = device + "p1" + } else { + partDevice = device + "1" + } + + // 格式化新分区 + var formatCmd string + switch req.FsType { + case "ext4": + formatCmd = fmt.Sprintf("mkfs.ext4 -F '%s'", partDevice) + case "ext3": + formatCmd = fmt.Sprintf("mkfs.ext3 -F '%s'", partDevice) + case "xfs": + formatCmd = fmt.Sprintf("mkfs.xfs -f '%s'", partDevice) + case "btrfs": + formatCmd = fmt.Sprintf("mkfs.btrfs -f '%s'", partDevice) + default: + Error(w, http.StatusUnprocessableEntity, s.t.Get("unsupported filesystem type: %s", req.FsType)) + return + } + + if _, err = shell.Execf(formatCmd); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to format partition: %v", err)) + return + } + + Success(w, nil) +} + +// GetLVMInfo 获取LVM信息 +func (s *ToolboxDiskService) GetLVMInfo(w http.ResponseWriter, r *http.Request) { + // 获取物理卷信息 + pvOutput, _ := shell.Execf("pvdisplay -C --noheadings --separator '|' -o pv_name,vg_name,pv_size,pv_free 2>/dev/null || echo ''") + // 获取卷组信息 + vgOutput, _ := shell.Execf("vgdisplay -C --noheadings --separator '|' -o vg_name,pv_count,lv_count,vg_size,vg_free 2>/dev/null || echo ''") + // 获取逻辑卷信息 + lvOutput, _ := shell.Execf("lvdisplay -C --noheadings --separator '|' -o lv_name,vg_name,lv_size,lv_path 2>/dev/null || echo ''") + + pvs := s.parseLVMOutput(pvOutput) + vgs := s.parseLVMOutput(vgOutput) + lvs := s.parseLVMOutput(lvOutput) + + Success(w, chix.M{ + "pvs": pvs, + "vgs": vgs, + "lvs": lvs, + }) +} + +// CreatePV 创建物理卷 +func (s *ToolboxDiskService) CreatePV(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskDevice](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if _, err = shell.Execf("pvcreate '/dev/%s'", req.Device); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to create physical volume: %v", err)) + return + } + + Success(w, nil) +} + +// CreateVG 创建卷组 +func (s *ToolboxDiskService) CreateVG(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskVG](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + // 构建设备列表,每个设备单独引用 + var deviceArgs []string + for _, dev := range req.Devices { + deviceArgs = append(deviceArgs, fmt.Sprintf("'%s'", dev)) + } + + if _, err = shell.Execf("vgcreate '%s' %s", req.Name, strings.Join(deviceArgs, " ")); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to create volume group: %v", err)) + return + } + + Success(w, nil) +} + +// CreateLV 创建逻辑卷 +func (s *ToolboxDiskService) CreateLV(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskLV](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + // 验证逻辑卷大小(必须为正数) + if req.Size <= 0 { + Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid logical volume size")) + return + } + + // 创建逻辑卷 + if _, err = shell.Execf("lvcreate -L '%dG' -n '%s' '%s'", req.Size, req.Name, req.VGName); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to create logical volume: %v", err)) + return + } + + Success(w, nil) +} + +// RemovePV 删除物理卷 +func (s *ToolboxDiskService) RemovePV(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskDevice](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if _, err = shell.Execf("pvremove '%s'", req.Device); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to remove physical volume: %v", err)) + return + } + + Success(w, nil) +} + +// RemoveVG 删除卷组 +func (s *ToolboxDiskService) RemoveVG(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskVGName](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if _, err = shell.Execf("vgremove -f '%s'", req.Name); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to remove volume group: %v", err)) + return + } + + Success(w, nil) +} + +// RemoveLV 删除逻辑卷 +func (s *ToolboxDiskService) RemoveLV(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskLVPath](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + if _, err = shell.Execf("lvremove -f '%s'", req.Path); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to remove logical volume: %v", err)) + return + } + + Success(w, nil) +} + +// ExtendLV 扩容逻辑卷 +func (s *ToolboxDiskService) ExtendLV(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskExtendLV](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + // 验证扩容大小为正整数 + if req.Size <= 0 { + Error(w, http.StatusUnprocessableEntity, s.t.Get("invalid size")) + return + } + + // 扩容逻辑卷 + if _, err = shell.Execf("lvextend -L +%dG '%s'", req.Size, req.Path); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to extend logical volume: %v", err)) + return + } + + // 扩展文件系统 + if req.Resize { + // 检测文件系统类型并扩展 + fsType, _ := shell.Execf("blkid -o value -s TYPE '%s'", req.Path) + fsType = strings.TrimSpace(fsType) + + switch fsType { + case "ext4", "ext3": + if _, err = shell.Execf("resize2fs '%s'", req.Path); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to resize filesystem: %v", err)) + return + } + case "xfs": + // XFS需要挂载后才能扩展 + mountPoint, _ := shell.Execf("findmnt -n -o TARGET '%s'", req.Path) + mountPoint = strings.TrimSpace(mountPoint) + if mountPoint != "" { + if _, err = shell.Execf("xfs_growfs '%s'", mountPoint); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to resize filesystem: %v", err)) + return + } + } else { + // XFS未挂载时,返回错误信息 + Error(w, http.StatusInternalServerError, s.t.Get("xfs filesystem is not mounted, logical volume has been extended but filesystem was not resized")) + return + } + case "btrfs": + // btrfs需要挂载后才能扩展 + mountPoint, _ := shell.Execf("findmnt -n -o TARGET '%s'", req.Path) + mountPoint = strings.TrimSpace(mountPoint) + if mountPoint != "" { + // 扩展到当前可用的最大空间 + if _, err = shell.Execf("btrfs filesystem resize max '%s'", mountPoint); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to resize filesystem: %v", err)) + return + } + } else { + // btrfs未挂载时,返回错误信息 + Error(w, http.StatusInternalServerError, s.t.Get("btrfs filesystem is not mounted, logical volume has been extended but filesystem was not resized")) + return + } + } + } + + Success(w, nil) +} + +// parseLVMOutput 解析LVM命令输出 +// 将LVM命令的表格输出解析为map数组,每行数据的字段以field_0, field_1...命名 +var spaceRegex = regexp.MustCompile(`\s+`) + +func (s *ToolboxDiskService) parseLVMOutput(output string) []map[string]string { + lines := strings.Split(strings.TrimSpace(output), "\n") + var result []map[string]string + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" { + continue + } + + line = spaceRegex.ReplaceAllString(line, " ") + + fields := strings.Split(line, "|") + item := make(map[string]string) + + for i, field := range fields { + item[fmt.Sprintf("field_%d", i)] = strings.TrimSpace(field) + } + + result = append(result, item) + } + + return result +} + +// GetFstab 获取 fstab 列表 +func (s *ToolboxDiskService) GetFstab(w http.ResponseWriter, r *http.Request) { + content, err := shell.Execf("cat /etc/fstab") + if err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to read fstab: %v", err)) + return + } + + var entries []request.ToolboxDiskFstabEntry + lines := strings.Split(content, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + // 跳过空行和注释 + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Fields(line) + if len(fields) >= 4 { + entry := request.ToolboxDiskFstabEntry{ + Device: fields[0], + MountPoint: fields[1], + FsType: fields[2], + Options: fields[3], + } + if len(fields) >= 5 { + entry.Dump = fields[4] + } + if len(fields) >= 6 { + entry.Pass = fields[5] + } + entries = append(entries, entry) + } + } + + Success(w, entries) +} + +// DeleteFstab 删除 fstab 条目 +func (s *ToolboxDiskService) DeleteFstab(w http.ResponseWriter, r *http.Request) { + req, err := Bind[request.ToolboxDiskFstabDelete](r) + if err != nil { + Error(w, http.StatusUnprocessableEntity, "%v", err) + return + } + + // 不允许删除根目录挂载 + if req.MountPoint == "/" { + Error(w, http.StatusBadRequest, s.t.Get("cannot delete root mount point")) + return + } + + if _, err = shell.Execf(`sed -i 's@^[^#].*\s%s\s.*$@@g' /etc/fstab`, req.MountPoint); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to delete fstab entry: %v", err)) + return + } + + if _, err = shell.Execf("mount -a"); err != nil { + Error(w, http.StatusInternalServerError, s.t.Get("failed to remount filesystems: %v", err)) + return + } + + Success(w, nil) +} diff --git a/web/src/api/panel/toolbox-disk/index.ts b/web/src/api/panel/toolbox-disk/index.ts new file mode 100644 index 00000000..7ca84e7e --- /dev/null +++ b/web/src/api/panel/toolbox-disk/index.ts @@ -0,0 +1,46 @@ +import { http } from '@/utils' + +export default { + // 获取磁盘列表 + list: (): any => http.Get('/toolbox_disk/list'), + // 获取分区列表 + partitions: (device: string): any => http.Post('/toolbox_disk/partitions', { device }), + // 挂载分区 + mount: ( + device: string, + path: string, + write_fstab: boolean = false, + mount_option: string = '' + ): any => http.Post('/toolbox_disk/mount', { device, path, write_fstab, mount_option }), + // 卸载分区 + umount: (path: string): any => http.Post('/toolbox_disk/umount', { path }), + // 格式化分区 + format: (device: string, fs_type: string): any => + http.Post('/toolbox_disk/format', { device, fs_type }), + // 初始化磁盘 + init: (device: string, fs_type: string): any => + http.Post('/toolbox_disk/init', { device, fs_type }), + // 获取 fstab 列表 + fstabList: (): any => http.Get('/toolbox_disk/fstab'), + // 删除 fstab 条目 + fstabDelete: (mount_point: string): any => http.Delete('/toolbox_disk/fstab', { mount_point }), + // 获取LVM信息 + lvmInfo: (): any => http.Get('/toolbox_disk/lvm'), + // 创建物理卷 + createPV: (device: string): any => http.Post('/toolbox_disk/lvm/pv', { device }), + // 删除物理卷 + removePV: (device: string): any => http.Delete('/toolbox_disk/lvm/pv', { device }), + // 创建卷组 + createVG: (name: string, devices: string[]): any => + http.Post('/toolbox_disk/lvm/vg', { name, devices }), + // 删除卷组 + removeVG: (name: string): any => http.Delete('/toolbox_disk/lvm/vg', { name }), + // 创建逻辑卷 + createLV: (name: string, vg_name: string, size: number): any => + http.Post('/toolbox_disk/lvm/lv', { name, vg_name, size }), + // 删除逻辑卷 + removeLV: (path: string): any => http.Delete('/toolbox_disk/lvm/lv', { path }), + // 扩容逻辑卷 + extendLV: (path: string, size: number, resize: boolean): any => + http.Post('/toolbox_disk/lvm/lv/extend', { path, size, resize }) +} diff --git a/web/src/views/toolbox/DiskView.vue b/web/src/views/toolbox/DiskView.vue new file mode 100644 index 00000000..ead4a0ec --- /dev/null +++ b/web/src/views/toolbox/DiskView.vue @@ -0,0 +1,1020 @@ + + + + + diff --git a/web/src/views/toolbox/IndexView.vue b/web/src/views/toolbox/IndexView.vue index ca016a10..01544139 100644 --- a/web/src/views/toolbox/IndexView.vue +++ b/web/src/views/toolbox/IndexView.vue @@ -4,8 +4,9 @@ defineOptions({ }) import BenchmarkView from '@/views/toolbox/BenchmarkView.vue' +import DiskView from '@/views/toolbox/DiskView.vue' import ProcessView from '@/views/toolbox/ProcessView.vue' -import SSHView from '@/views/toolbox/SSHView.vue' +import SshView from '@/views/toolbox/SshView.vue' import SystemView from '@/views/toolbox/SystemView.vue' import WebHookView from '@/views/toolbox/WebHookView.vue' import { useGettext } from 'vue3-gettext' @@ -21,6 +22,7 @@ const current = ref('process') + @@ -28,7 +30,8 @@ const current = ref('process') - + + diff --git a/web/src/views/toolbox/SSHView.vue b/web/src/views/toolbox/SshView.vue similarity index 95% rename from web/src/views/toolbox/SSHView.vue rename to web/src/views/toolbox/SshView.vue index 276c3a52..42ad7dce 100644 --- a/web/src/views/toolbox/SSHView.vue +++ b/web/src/views/toolbox/SshView.vue @@ -224,20 +224,16 @@ onMounted(() => {