From 5668bc1afd82b0cf5e1ef29c2beae0d370cb78fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=80=97=E5=AD=90?= Date: Sun, 13 Oct 2024 17:43:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E5=A4=87=E4=BB=BD=E9=87=8D?= =?UTF-8?q?=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/apps/mysql/service.go | 2 +- internal/apps/toolbox/service.go | 4 +- internal/biz/backup.go | 12 + internal/data/backup.go | 466 +++++++++++++++++++++++++++++ internal/data/cron.go | 40 ++- internal/data/website.go | 1 + internal/job/panel_task.go | 25 +- internal/route/cli.go | 165 +++++++++- internal/service/backup.go | 12 +- internal/service/cli.go | 234 +++++++++++++-- pkg/db/mysql.go | 25 ++ pkg/db/postgres.go | 17 ++ pkg/io/path.go | 28 ++ pkg/types/website.go | 1 + web/src/i18n/zh_CN.json | 2 +- web/src/views/home/IndexView.vue | 2 +- web/src/views/website/EditView.vue | 1 + web/src/views/website/types.ts | 6 +- 18 files changed, 967 insertions(+), 76 deletions(-) create mode 100644 internal/biz/backup.go create mode 100644 internal/data/backup.go diff --git a/internal/apps/mysql/service.go b/internal/apps/mysql/service.go index 1a38770c..370b2335 100644 --- a/internal/apps/mysql/service.go +++ b/internal/apps/mysql/service.go @@ -108,7 +108,7 @@ func (s *Service) Load(w http.ResponseWriter, r *http.Request) { {`Max_used_connections\s+\|\s+(\d+)\s+\|`, "峰值连接数"}, {`Key_read_requests\s+\|\s+(\d+)\s+\|`, "索引命中率"}, {`Innodb_buffer_pool_reads\s+\|\s+(\d+)\s+\|`, "Innodb索引命中率"}, - {`Created_tmp_disk_tables\s+\|\s+(\d+)\s+\|`, "创建临时表到磁盘"}, + {`Created_tmp_disk_tables\s+\|\s+(\d+)\s+\|`, "创建临时表到硬盘"}, {`Open_tables\s+\|\s+(\d+)\s+\|`, "已打开的表"}, {`Select_full_join\s+\|\s+(\d+)\s+\|`, "没有使用索引的量"}, {`Select_full_range_join\s+\|\s+(\d+)\s+\|`, "没有索引的JOIN量"}, diff --git a/internal/apps/toolbox/service.go b/internal/apps/toolbox/service.go index e25a8719..d06a5bb1 100644 --- a/internal/apps/toolbox/service.go +++ b/internal/apps/toolbox/service.go @@ -129,11 +129,11 @@ func (s *Service) UpdateSWAP(w http.ResponseWriter, r *http.Request) { var free string free, err = shell.Execf("df -k %s | awk '{print $4}' | tail -n 1", app.Root) if err != nil { - service.Error(w, http.StatusInternalServerError, "获取磁盘空间失败") + service.Error(w, http.StatusInternalServerError, "获取硬盘空间失败") return } if cast.ToInt64(free)*1024 < req.Size*1024*1024 { - service.Error(w, http.StatusInternalServerError, "磁盘空间不足,当前剩余%s", str.FormatBytes(cast.ToFloat64(free))) + service.Error(w, http.StatusInternalServerError, "硬盘空间不足,当前剩余%s", str.FormatBytes(cast.ToFloat64(free))) return } diff --git a/internal/biz/backup.go b/internal/biz/backup.go new file mode 100644 index 00000000..fd04654e --- /dev/null +++ b/internal/biz/backup.go @@ -0,0 +1,12 @@ +package biz + +import "github.com/TheTNB/panel/pkg/types" + +type BackupRepo interface { + List(typ string) ([]*types.BackupFile, error) + Create(typ, target string, path ...string) error + Delete(typ, name string) error + CleanExpired(path, prefix string, save int) error + CutoffLog(path, target string) error + GetPath(typ string) (string, error) +} diff --git a/internal/data/backup.go b/internal/data/backup.go new file mode 100644 index 00000000..8cadea1a --- /dev/null +++ b/internal/data/backup.go @@ -0,0 +1,466 @@ +package data + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/gookit/color" + "github.com/shirou/gopsutil/disk" + + "github.com/TheTNB/panel/internal/app" + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/pkg/db" + "github.com/TheTNB/panel/pkg/io" + "github.com/TheTNB/panel/pkg/shell" + "github.com/TheTNB/panel/pkg/str" + "github.com/TheTNB/panel/pkg/types" +) + +type backupRepo struct { + setting biz.SettingRepo + website biz.WebsiteRepo +} + +func NewBackupRepo() biz.BackupRepo { + return &backupRepo{ + setting: NewSettingRepo(), + website: NewWebsiteRepo(), + } +} + +// List 备份列表 +func (r *backupRepo) List(typ string) ([]*types.BackupFile, error) { + backupPath, err := r.GetPath(typ) + if err != nil { + return nil, err + } + + files, err := io.ReadDir(backupPath) + if err != nil { + return nil, err + } + + var backupList []*types.BackupFile + for _, file := range files { + info, err := file.Info() + if err != nil { + continue + } + backupList = append(backupList, &types.BackupFile{ + Name: file.Name(), + Size: str.FormatBytes(float64(info.Size())), + }) + } + + return backupList, nil +} + +// Create 创建备份 +func (r *backupRepo) Create(typ, target string, path ...string) error { + defPath, err := r.GetPath(typ) + if err != nil { + return err + } + if len(path) > 0 && path[0] != "" { + defPath = path[0] + } + + switch typ { + case "website": + return r.createWebsite(defPath, target) + case "mysql": + return r.createMySQL(defPath, target) + case "postgres": + return r.createPostgres(defPath, target) + case "panel": + return r.createPanel(defPath) + + } + + return errors.New("未知备份类型") +} + +// Delete 删除备份 +func (r *backupRepo) Delete(typ, name string) error { + path, err := r.GetPath(typ) + if err != nil { + return err + } + + file := filepath.Join(path, name) + return io.Remove(file) +} + +// CutoffLog 切割日志 +// path 保存目录绝对路径 +// target 待切割日志文件绝对路径 +func (r *backupRepo) CutoffLog(path, target string) error { + if !io.Exists(target) { + return errors.New("日志文件不存在") + } + + to := filepath.Join(path, fmt.Sprintf("%s_%s.zip", time.Now().Format("20060102150405"), filepath.Base(target))) + if err := io.Compress([]string{target}, to, io.Zip); err != nil { + return err + } + + return io.Remove(target) +} + +// CleanExpired 清理过期备份 +// path 备份目录绝对路径 +// prefix 目标文件前缀 +// save 保存份数 +func (r *backupRepo) CleanExpired(path, prefix string, save int) error { + files, err := io.ReadDir(path) + if err != nil { + return err + } + + var filtered []os.FileInfo + for _, file := range files { + if strings.HasPrefix(file.Name(), prefix) && strings.HasSuffix(file.Name(), ".zip") { + info, err := os.Stat(filepath.Join(path, file.Name())) + if err != nil { + continue + } + filtered = append(filtered, info) + } + } + + // 排序所有备份文件,从新到旧 + slices.SortFunc(filtered, func(a, b os.FileInfo) int { + if a.ModTime().After(b.ModTime()) { + return -1 + } + if a.ModTime().Before(b.ModTime()) { + return 1 + } + return 0 + }) + if len(filtered) <= save { + return nil + } + + // 切片保留 save 份,删除剩余 + toDelete := filtered[save:] + for _, file := range toDelete { + filePath := filepath.Join(path, file.Name()) + if err = os.Remove(filePath); err != nil { + return err + } + } + + return nil +} + +// GetPath 获取备份路径 +func (r *backupRepo) GetPath(typ string) (string, error) { + backupPath, err := r.setting.Get(biz.SettingKeyBackupPath) + if err != nil { + return "", err + } + + backupPath = filepath.Join(backupPath, typ) + if !io.Exists(backupPath) { + if err = io.Mkdir(backupPath, 0644); err != nil { + return "", err + } + } + + return backupPath, nil +} + +// createWebsite 创建网站备份 +func (r *backupRepo) createWebsite(to string, name string) error { + website, err := r.website.GetByName(name) + if err != nil { + return err + } + + if err = r.preCheckPath(to, website.Path); err != nil { + return err + } + + backup := filepath.Join(to, fmt.Sprintf("%s_%s.zip", website.Name, time.Now().Format("20060102150405"))) + if _, err = shell.Execf(`cd '%s' && zip -r '%s' .`, website.Path, backup); err != nil { + return err + } + + return nil +} + +// createMySQL 创建 MySQL 备份 +func (r *backupRepo) createMySQL(to string, name string) error { + rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword) + if err != nil { + return err + } + mysql, err := db.NewMySQL("root", rootPassword, "/tmp/mysql.sock") + if err != nil { + return err + } + if exist, _ := mysql.DatabaseExists(name); !exist { + return fmt.Errorf("数据库不存在:%s", name) + } + size, err := mysql.DatabaseSize(name) + if err != nil { + return err + } + if err = r.preCheckDB(to, size); err != nil { + return err + } + + if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil { + return err + } + backup := filepath.Join(to, fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405"))) + if _, err = shell.Execf(`mysqldump -u root '%s' > '%s'`, name, backup); err != nil { + return err + } + if err = os.Unsetenv("MYSQL_PWD"); err != nil { + return err + } + + if err = io.Compress([]string{backup}, backup+".zip", io.Zip); err != nil { + return err + } + if err = io.Remove(backup); err != nil { + return err + } + + return nil +} + +// createPostgres 创建 PostgreSQL 备份 +func (r *backupRepo) createPostgres(to string, name string) error { + postgres, err := db.NewPostgres("postgres", "", "127.0.0.1", fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", app.Root), 5432) + if err != nil { + return err + } + if exist, _ := postgres.DatabaseExist(name); !exist { + return fmt.Errorf("数据库不存在:%s", name) + } + size, err := postgres.DatabaseSize(name) + if err != nil { + return err + } + if err = r.preCheckDB(to, size); err != nil { + return err + } + + backup := filepath.Join(to, fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405"))) + if _, err = shell.Execf(`su - postgres -c "pg_dump '%s'" > '%s'`, name, backup); err != nil { + return err + } + + if err = io.Compress([]string{backup}, backup+".zip", io.Zip); err != nil { + return err + } + if err = io.Remove(backup); err != nil { + return err + } + + return nil +} + +// createPanel 创建面板备份 +func (r *backupRepo) createPanel(to string) error { + backup := filepath.Join(to, fmt.Sprintf("panel_%s.zip", time.Now().Format("20060102150405"))) + + if err := r.preCheckPath(to, filepath.Join(app.Root, "panel")); err != nil { + return err + } + + return io.Compress([]string{ + filepath.Join(app.Root, "panel"), + "/usr/local/sbin/panel-cli", + "/usr/local/etc/panel/config.yml", + }, backup, io.Zip) +} + +// restoreWebsite 恢复网站备份 +func (r *backupRepo) restoreWebsite(backup, name string) error { + if !io.Exists(backup) { + return errors.New("备份文件不存在") + } + + website, err := r.website.GetByName(name) + if err != nil { + return err + } + format, err := io.FormatArchiveByPath(backup) + if err != nil { + return err + } + + if err = io.Remove(website.Path); err != nil { + return err + } + if err = io.UnCompress(backup, website.Path, format); err != nil { + return err + } + if err = io.Chmod(website.Path, 0755); err != nil { + return err + } + if err = io.Chown(website.Path, "www", "www"); err != nil { + return err + } + + return nil +} + +// restoreMySQL 恢复 MySQL 备份 +func (r *backupRepo) restoreMySQL(backup, name string) error { + if !io.Exists(backup) { + return errors.New("备份文件不存在") + } + + rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword) + if err != nil { + return err + } + mysql, err := db.NewMySQL("root", rootPassword, "/tmp/mysql.sock") + if err != nil { + return err + } + if exist, _ := mysql.DatabaseExists(name); !exist { + return fmt.Errorf("数据库不存在:%s", name) + } + if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil { + return err + } + + if !strings.HasSuffix(backup, ".sql") { + backup, err = r.autoUnCompressSQL(backup) + if err != nil { + return err + } + defer io.Remove(filepath.Dir(backup)) + } + + if _, err = shell.Execf(`mysql -u root '%s' < '%s'`, name, backup); err != nil { + return err + } + + return os.Unsetenv("MYSQL_PWD") +} + +// restorePostgres 恢复 PostgreSQL 备份 +func (r *backupRepo) restorePostgres(backup, name string) error { + if !io.Exists(backup) { + return errors.New("备份文件不存在") + } + + postgres, err := db.NewPostgres("postgres", "", "127.0.0.1", fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", app.Root), 5432) + if err != nil { + return err + } + if exist, _ := postgres.DatabaseExist(name); !exist { + return fmt.Errorf("数据库不存在:%s", name) + } + + if !strings.HasSuffix(backup, ".sql") { + backup, err = r.autoUnCompressSQL(backup) + if err != nil { + return err + } + defer io.Remove(filepath.Dir(backup)) + } + + if _, err = shell.Execf(`su - postgres -c "psql '%s'" < '%s'`, name, backup); err != nil { + return err + } + + return nil +} + +// preCheckPath 预检空间和 inode 是否足够 +// to 备份保存目录 +// path 待备份目录 +func (r *backupRepo) preCheckPath(to, path string) error { + size, err := io.SizeX(path) + if err != nil { + return err + } + files, err := io.CountX(path) + if err != nil { + return err + } + + usage, err := disk.Usage(to) + if err != nil { + return err + } + + color.Greenln(fmt.Sprintf("|-目标大小:%s", str.FormatBytes(float64(size)))) + color.Greenln(fmt.Sprintf("|-目标文件数:%d", files)) + color.Greenln(fmt.Sprintf("|-备份目录可用空间:%s", str.FormatBytes(float64(usage.Free)))) + color.Greenln(fmt.Sprintf("|-备份目录可用Inode:%d", usage.InodesFree)) + + if uint64(size) > usage.Free { + return errors.New("备份目录空间不足") + } + if uint64(files) > usage.InodesFree { + return errors.New("备份目录Inode不足") + } + + return nil +} + +// preCheckDB 预检空间和 inode 是否足够 +// to 备份保存目录 +// size 数据库大小 +func (r *backupRepo) preCheckDB(to string, size int64) error { + usage, err := disk.Usage(to) + if err != nil { + return err + } + + color.Greenln(fmt.Sprintf("|-目标大小:%s", str.FormatBytes(float64(size)))) + color.Greenln(fmt.Sprintf("|-备份目录可用空间:%s", str.FormatBytes(float64(usage.Free)))) + color.Greenln(fmt.Sprintf("|-备份目录可用Inode:%d", usage.InodesFree)) + + if uint64(size) > usage.Free { + return errors.New("备份目录空间不足") + } + + return nil +} + +// autoUnCompressSQL 自动处理压缩文件 +func (r *backupRepo) autoUnCompressSQL(backup string) (string, error) { + temp, err := io.TempDir(backup) + if err != nil { + return "", err + } + + format, err := io.FormatArchiveByPath(backup) + if err != nil { + return "", err + } + if err = io.UnCompress(backup, temp, format); err != nil { + return "", err + } + + backup = "" // 置空,防止干扰后续判断 + if files, err := os.ReadDir(temp); err == nil { + if len(files) != 1 { + return "", fmt.Errorf("压缩文件中包含的文件数量不为1,实际为%d", len(files)) + } + if strings.HasSuffix(files[0].Name(), ".sql") { + backup = filepath.Join(temp, files[0].Name()) + } + } + + if backup == "" { + return "", errors.New("无法找到.sql备份文件") + } + + return backup, nil +} diff --git a/internal/data/cron.go b/internal/data/cron.go index c405cd6b..54e126a6 100644 --- a/internal/data/cron.go +++ b/internal/data/cron.go @@ -60,26 +60,26 @@ func (r *cronRepo) Create(req *request.CronCreate) error { var script string if req.Type == "backup" { - if len(req.BackupPath) == 0 { - req.BackupPath, _ = r.settingRepo.Get(biz.SettingKeyBackupPath) - if len(req.BackupPath) == 0 { - return errors.New("备份路径不能为空") - } - req.BackupPath = filepath.Join(req.BackupPath, req.BackupType) - } - script = fmt.Sprintf(`#!/bin/bash + if req.BackupType == "website" { + script = fmt.Sprintf(`#!/bin/bash export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH -# 耗子面板 - 数据备份脚本 +# 耗子面板 - 网站备份脚本 -type=%s -path=%s -name=%s -save=%d +panel-cli backup website -n %s -p %s +panel-cli backup clear -t website -f %s -s %d -p %s +`, req.Target, req.BackupPath, req.Target, req.Save, req.BackupPath) + } + if req.BackupType == "mysql" || req.BackupType == "postgres" { + script = fmt.Sprintf(`#!/bin/bash +export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH -# 执行备份 -panel backup ${type} ${name} ${path} ${save} -`, req.BackupType, req.BackupPath, req.Target, req.Save) +# 耗子面板 - 数据库备份脚本 + +panel-cli backup database -t %s -n %s -p %s +panel-cli backup clear -t %s -f %s -s %d -p %s +`, req.BackupType, req.Target, req.BackupPath, req.BackupType, req.Target, req.Save, req.BackupPath) + } } if req.Type == "cutoff" { script = fmt.Sprintf(`#!/bin/bash @@ -87,12 +87,10 @@ export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/usr/local/sbin:$PATH # 耗子面板 - 日志切割脚本 -name=%s -save=%d - # 执行切割 -panel cutoff ${name} ${save} -`, req.Target, req.Save) +panel-cli cutoff website -n %s -p %s +panel-cli cutoff clear -t website -f %s -s %d -p %s +`, req.Target, req.BackupPath, req.Target, req.Save, req.BackupPath) } shellDir := fmt.Sprintf("%s/server/cron/", app.Root) diff --git a/internal/data/website.go b/internal/data/website.go index 5ee96fa7..8827af07 100644 --- a/internal/data/website.go +++ b/internal/data/website.go @@ -66,6 +66,7 @@ func (r *websiteRepo) Get(id uint) (*types.WebsiteSetting, error) { } setting := new(types.WebsiteSetting) + setting.ID = website.ID setting.Name = website.Name setting.Path = website.Path setting.SSL = website.SSL diff --git a/internal/job/panel_task.go b/internal/job/panel_task.go index 13b77375..0444b63e 100644 --- a/internal/job/panel_task.go +++ b/internal/job/panel_task.go @@ -1,29 +1,27 @@ package job import ( - "path/filepath" "runtime" "runtime/debug" - "time" "go.uber.org/zap" "github.com/TheTNB/panel/internal/app" "github.com/TheTNB/panel/internal/biz" "github.com/TheTNB/panel/internal/data" - "github.com/TheTNB/panel/pkg/io" - "github.com/TheTNB/panel/pkg/shell" "github.com/TheTNB/panel/pkg/types" ) // PanelTask 面板每日任务 type PanelTask struct { - appRepo biz.AppRepo + appRepo biz.AppRepo + backupRepo biz.BackupRepo } func NewPanelTask() *PanelTask { return &PanelTask{ - appRepo: data.NewAppRepo(), + appRepo: data.NewAppRepo(), + backupRepo: data.NewBackupRepo(), } } @@ -37,19 +35,20 @@ func (receiver *PanelTask) Run() { } // 备份面板 - if err := io.Compress([]string{"/www/panel"}, filepath.Join(app.Root, "backup", "panel", "panel-"+time.Now().Format(time.DateOnly)+".zip"), io.Zip); err != nil { - types.Status = types.StatusFailed + if err := receiver.backupRepo.Create("panel", ""); err != nil { app.Logger.Error("备份面板失败", zap.Error(err)) } - // 清理 7 天前的备份 - if _, err := shell.Execf(`find %s -mtime +7 -name "*.zip" -exec rm -rf {} \;`, filepath.Join(app.Root, "backup", "panel")); err != nil { - types.Status = types.StatusFailed - app.Logger.Error("清理面板备份失败", zap.Error(err)) + // 清理备份 + path, err := receiver.backupRepo.GetPath("panel") + if err == nil { + if err = receiver.backupRepo.CleanExpired(path, "panel_", 10); err != nil { + app.Logger.Error("清理面板备份失败", zap.Error(err)) + } } // 更新商店缓存 - if err := receiver.appRepo.UpdateCache(); err != nil { + if err = receiver.appRepo.UpdateCache(); err != nil { app.Logger.Error("更新商店缓存失败", zap.Error(err)) } diff --git a/internal/route/cli.go b/internal/route/cli.go index dbf4ad57..9e58b457 100644 --- a/internal/route/cli.go +++ b/internal/route/cli.go @@ -98,18 +98,62 @@ func Cli() []*cli.Command { Commands: []*cli.Command{ { Name: "create", - Usage: "创建新站点", + Usage: "创建新网站", Action: cliService.WebsiteCreate, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "网站名称", + Aliases: []string{"n"}, + Required: true, + }, + &cli.StringSliceFlag{ + Name: "domains", + Usage: "与网站关联的域名列表", + Aliases: []string{"d"}, + Required: true, + }, + &cli.UintSliceFlag{ + Name: "ports", + Usage: "网站使用的端口列表", + Aliases: []string{"p"}, + Required: true, + }, + &cli.StringFlag{ + Name: "path", + Usage: "网站托管的路径(不填则默认路径)", + }, + &cli.IntFlag{ + Name: "php", + Usage: "网站使用的 PHP 版本(不填不使用)", + }, + }, }, { Name: "remove", - Usage: "移除站点", + Usage: "移除网站", Action: cliService.WebsiteRemove, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "网站名称", + Aliases: []string{"n"}, + Required: true, + }, + }, }, { Name: "delete", - Usage: "删除站点(包括站点目录、同名数据库)", + Usage: "删除网站(包括网站目录、同名数据库)", Action: cliService.WebsiteDelete, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Usage: "网站名称", + Aliases: []string{"n"}, + Required: true, + }, + }, }, { Name: "write", @@ -121,22 +165,91 @@ func Cli() []*cli.Command { }, { Name: "backup", - Usage: "备份数据", + Usage: "数据备份", Commands: []*cli.Command{ { Name: "website", Usage: "备份网站", Action: cliService.BackupWebsite, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "网站名称", + Required: true, + }, + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "保存目录(不填则默认路径)", + }, + }, }, { Name: "database", Usage: "备份数据库", Action: cliService.BackupDatabase, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "数据库类型", + Required: true, + }, + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "数据库名称", + Required: true, + }, + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "保存目录(不填则默认路径)", + }, + }, }, { Name: "panel", Usage: "备份面板", Action: cliService.BackupPanel, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "保存目录(不填则默认路径)", + }, + }, + }, + { + Name: "clear", + Usage: "清理备份", + Action: cliService.BackupClear, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "备份类型", + Required: true, + }, + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "备份文件", + Required: true, + }, + &cli.IntFlag{ + Name: "save", + Aliases: []string{"s"}, + Usage: "保存份数", + Required: true, + }, + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "备份目录(不填则默认路径)", + }, + }, }, }, }, @@ -148,6 +261,50 @@ func Cli() []*cli.Command { Name: "website", Usage: "网站", Action: cliService.CutoffWebsite, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "网站名称", + Required: true, + }, + + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "保存目录(不填则默认路径)", + }, + }, + }, + { + Name: "clear", + Usage: "清理切割的日志", + Action: cliService.CutoffClear, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "type", + Aliases: []string{"t"}, + Usage: "切割类型", + Required: true, + }, + &cli.StringFlag{ + Name: "file", + Aliases: []string{"f"}, + Usage: "切割文件", + Required: true, + }, + &cli.IntFlag{ + Name: "save", + Aliases: []string{"s"}, + Usage: "保存份数", + Required: true, + }, + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "切割目录(不填则默认路径)", + }, + }, }, }, }, diff --git a/internal/service/backup.go b/internal/service/backup.go index 2f27ff27..431570d6 100644 --- a/internal/service/backup.go +++ b/internal/service/backup.go @@ -1,12 +1,20 @@ package service -import "net/http" +import ( + "net/http" + + "github.com/TheTNB/panel/internal/biz" + "github.com/TheTNB/panel/internal/data" +) type BackupService struct { + backupRepo biz.BackupRepo } func NewBackupService() *BackupService { - return &BackupService{} + return &BackupService{ + backupRepo: data.NewBackupRepo(), + } } func (s *BackupService) List(w http.ResponseWriter, r *http.Request) { diff --git a/internal/service/cli.go b/internal/service/cli.go index d0e1fc10..cb13669e 100644 --- a/internal/service/cli.go +++ b/internal/service/cli.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "time" "github.com/go-rat/utils/hash" "github.com/goccy/go-yaml" @@ -16,6 +17,7 @@ import ( "github.com/TheTNB/panel/internal/app" "github.com/TheTNB/panel/internal/biz" "github.com/TheTNB/panel/internal/data" + "github.com/TheTNB/panel/internal/http/request" "github.com/TheTNB/panel/pkg/api" "github.com/TheTNB/panel/pkg/io" "github.com/TheTNB/panel/pkg/str" @@ -25,33 +27,54 @@ import ( ) type CliService struct { - api *api.API - app biz.AppRepo - user biz.UserRepo - setting biz.SettingRepo - hash hash.Hasher + hr string + api *api.API + appRepo biz.AppRepo + userRepo biz.UserRepo + settingRepo biz.SettingRepo + backupRepo biz.BackupRepo + websiteRepo biz.WebsiteRepo + hash hash.Hasher } func NewCliService() *CliService { return &CliService{ - api: api.NewAPI(app.Version), - app: data.NewAppRepo(), - user: data.NewUserRepo(), - setting: data.NewSettingRepo(), - hash: hash.NewArgon2id(), + hr: `+----------------------------------------------------`, + api: api.NewAPI(app.Version), + appRepo: data.NewAppRepo(), + userRepo: data.NewUserRepo(), + settingRepo: data.NewSettingRepo(), + backupRepo: data.NewBackupRepo(), + websiteRepo: data.NewWebsiteRepo(), + hash: hash.NewArgon2id(), } } func (s *CliService) Restart(ctx context.Context, cmd *cli.Command) error { - return systemctl.Restart("panel") + if err := systemctl.Restart("panel"); err != nil { + return err + } + + color.Greenln("面板服务已重启") + return nil } func (s *CliService) Stop(ctx context.Context, cmd *cli.Command) error { - return systemctl.Stop("panel") + if err := systemctl.Stop("panel"); err != nil { + return err + } + + color.Greenln("面板服务已停止") + return nil } func (s *CliService) Start(ctx context.Context, cmd *cli.Command) error { - return systemctl.Start("panel") + if err := systemctl.Start("panel"); err != nil { + return err + } + + color.Greenln("面板服务已启动") + return nil } func (s *CliService) Update(ctx context.Context, cmd *cli.Command) error { @@ -66,7 +89,7 @@ func (s *CliService) Update(ctx context.Context, cmd *cli.Command) error { } ver, url, checksum := panel.Version, download.URL, download.Checksum - return s.setting.UpdatePanel(ver, url, checksum) + return s.settingRepo.UpdatePanel(ver, url, checksum) } func (s *CliService) Info(ctx context.Context, cmd *cli.Command) error { @@ -153,6 +176,7 @@ func (s *CliService) UserName(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("用户名修改失败:%v", err) } + color.Greenln(fmt.Sprintf("用户 %s 修改为 %s 成功", oldUsername, newUsername)) return nil } @@ -184,6 +208,7 @@ func (s *CliService) UserPassword(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("密码修改失败:%v", err) } + color.Greenln(fmt.Sprintf("用户 %s 密码修改成功", username)) return nil } @@ -209,6 +234,7 @@ func (s *CliService) HTTPSOn(ctx context.Context, cmd *cli.Command) error { return err } + color.Greenln("已开启HTTPS") return s.Restart(ctx, cmd) } @@ -234,6 +260,7 @@ func (s *CliService) HTTPSOff(ctx context.Context, cmd *cli.Command) error { return err } + color.Greenln("已关闭HTTPS") return s.Restart(ctx, cmd) } @@ -259,6 +286,8 @@ func (s *CliService) EntranceOn(ctx context.Context, cmd *cli.Command) error { return err } + color.Greenln("已开启访问入口") + color.Greenln(fmt.Sprintf("访问入口:%s", config.HTTP.Entrance)) return s.Restart(ctx, cmd) } @@ -284,6 +313,7 @@ func (s *CliService) EntranceOff(ctx context.Context, cmd *cli.Command) error { return err } + color.Greenln("已关闭访问入口") return s.Restart(ctx, cmd) } @@ -314,46 +344,191 @@ func (s *CliService) Port(ctx context.Context, cmd *cli.Command) error { return err } + color.Greenln(fmt.Sprintf("已修改端口为 %d", port)) return s.Restart(ctx, cmd) } func (s *CliService) WebsiteCreate(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + var ports []uint + for _, port := range cmd.IntSlice("ports") { + if port < 1 || port > 65535 { + return fmt.Errorf("端口范围错误") + } + ports = append(ports, uint(port)) + } + req := &request.WebsiteCreate{ + Name: cmd.String("name"), + Domains: cmd.StringSlice("domains"), + Ports: ports, + Path: cmd.String("path"), + PHP: int(cmd.Int("php")), + DB: false, + } + + website, err := s.websiteRepo.Create(req) + if err != nil { + return err + } + + color.Greenln(fmt.Sprintf("网站 %s 创建成功", website.Name)) return nil } func (s *CliService) WebsiteRemove(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + website, err := s.websiteRepo.GetByName(cmd.String("name")) + if err != nil { + return err + } + req := &request.WebsiteDelete{ + ID: website.ID, + } + + if err = s.websiteRepo.Delete(req); err != nil { + return err + } + + color.Greenln(fmt.Sprintf("网站 %s 移除成功", website.Name)) return nil } func (s *CliService) WebsiteDelete(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + website, err := s.websiteRepo.GetByName(cmd.String("name")) + if err != nil { + return err + } + req := &request.WebsiteDelete{ + ID: website.ID, + Path: true, + DB: true, + } + + if err = s.websiteRepo.Delete(req); err != nil { + return err + } + + color.Greenln(fmt.Sprintf("网站 %s 删除成功", website.Name)) return nil } func (s *CliService) WebsiteWrite(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + println("not support") return nil } func (s *CliService) BackupWebsite(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("★ 开始备份 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + color.Greenln("|-备份类型:网站") + color.Greenln(fmt.Sprintf("|-备份目标:%s", cmd.String("name"))) + if err := s.backupRepo.Create("website", cmd.String("name"), cmd.String("path")); err != nil { + return fmt.Errorf("|-备份失败:%v", err) + } + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("☆ 备份成功 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) return nil } func (s *CliService) BackupDatabase(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("★ 开始备份 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + color.Greenln("|-备份类型:数据库") + color.Greenln(fmt.Sprintf("|-数据库:%s", cmd.String("type"))) + color.Greenln(fmt.Sprintf("|-备份目标:%s", cmd.String("name"))) + if err := s.backupRepo.Create(cmd.String("type"), cmd.String("name"), cmd.String("path")); err != nil { + return fmt.Errorf("|-备份失败:%v", err) + } + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("☆ 备份成功 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) return nil } func (s *CliService) BackupPanel(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("★ 开始备份 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + color.Greenln("|-备份类型:面板") + if err := s.backupRepo.Create("panel", "", cmd.String("path")); err != nil { + return fmt.Errorf("|-备份失败:%v", err) + } + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("☆ 备份成功 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + return nil +} + +func (s *CliService) BackupClear(ctx context.Context, cmd *cli.Command) error { + path, err := s.backupRepo.GetPath(cmd.String("type")) + if err != nil { + return err + } + if cmd.String("path") != "" { + path = cmd.String("path") + } + + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("★ 开始清理 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("|-清理类型:%s", cmd.String("type"))) + color.Greenln(fmt.Sprintf("|-清理目标:%s", cmd.String("file"))) + color.Greenln(fmt.Sprintf("|-保留份数:%d", cmd.Int("save"))) + if err = s.backupRepo.CleanExpired(path, cmd.String("file"), int(cmd.Int("save"))); err != nil { + return fmt.Errorf("|-清理失败:%v", err) + } + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("☆ 清理成功 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) return nil } func (s *CliService) CutoffWebsite(ctx context.Context, cmd *cli.Command) error { - println("Hello, World!") + website, err := s.websiteRepo.GetByName(cmd.String("name")) + if err != nil { + return err + } + path := filepath.Join(app.Root, "wwwlogs") + if cmd.String("path") != "" { + path = cmd.String("path") + } + + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("★ 开始切割日志 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + color.Greenln("|-切割类型:网站") + color.Greenln(fmt.Sprintf("|-切割目标:%s", website.Name)) + if err = s.backupRepo.CutoffLog(path, filepath.Join(app.Root, "wwwlogs", website.Name+".log")); err != nil { + return err + } + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("☆ 切割成功 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + return nil +} + +func (s *CliService) CutoffClear(ctx context.Context, cmd *cli.Command) error { + if cmd.String("type") != "website" { + return errors.New("当前仅支持网站日志切割") + } + path := filepath.Join(app.Root, "wwwlogs") + if cmd.String("path") != "" { + path = cmd.String("path") + } + + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("★ 开始清理切割日志 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("|-清理类型:%s", cmd.String("type"))) + color.Greenln(fmt.Sprintf("|-清理目标:%s", cmd.String("file"))) + color.Greenln(fmt.Sprintf("|-保留份数:%d", cmd.Int("save"))) + if err := s.backupRepo.CleanExpired(path, cmd.String("file"), int(cmd.Int("save"))); err != nil { + return err + } + color.Greenln(s.hr) + color.Greenln(fmt.Sprintf("☆ 清理成功 [%s]", time.Now().Format(time.DateTime))) + color.Greenln(s.hr) return nil } @@ -364,7 +539,7 @@ func (s *CliService) AppInstall(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("参数不能为空") } - if err := s.app.Install(channel, slug); err != nil { + if err := s.appRepo.Install(channel, slug); err != nil { return fmt.Errorf("应用安装失败:%v", err) } @@ -379,7 +554,7 @@ func (s *CliService) AppUnInstall(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("参数不能为空") } - if err := s.app.UnInstall(slug); err != nil { + if err := s.appRepo.UnInstall(slug); err != nil { return fmt.Errorf("应用卸载失败:%v", err) } @@ -496,7 +671,14 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("已经初始化过了") } - settings := []biz.Setting{{Key: biz.SettingKeyName, Value: "耗子面板"}, {Key: biz.SettingKeyMonitor, Value: "1"}, {Key: biz.SettingKeyMonitorDays, Value: "30"}, {Key: biz.SettingKeyBackupPath, Value: filepath.Join(app.Root, "backup")}, {Key: biz.SettingKeyWebsitePath, Value: filepath.Join(app.Root, "wwwroot")}, {Key: biz.SettingKeyVersion, Value: app.Version}} + settings := []biz.Setting{ + {Key: biz.SettingKeyName, Value: "耗子面板"}, + {Key: biz.SettingKeyMonitor, Value: "1"}, + {Key: biz.SettingKeyMonitorDays, Value: "30"}, + {Key: biz.SettingKeyBackupPath, Value: filepath.Join(app.Root, "backup")}, + {Key: biz.SettingKeyWebsitePath, Value: filepath.Join(app.Root, "wwwroot")}, + {Key: biz.SettingKeyVersion, Value: app.Version}, + } if err := app.Orm.Create(&settings).Error; err != nil { return fmt.Errorf("初始化失败:%v", err) } @@ -534,5 +716,5 @@ func (s *CliService) Init(ctx context.Context, cmd *cli.Command) error { } // 初始化应用中心缓存 - return s.app.UpdateCache() + return s.appRepo.UpdateCache() } diff --git a/pkg/db/mysql.go b/pkg/db/mysql.go index 62f56b31..ca61d628 100644 --- a/pkg/db/mysql.go +++ b/pkg/db/mysql.go @@ -72,6 +72,31 @@ func (m *MySQL) DatabaseDrop(name string) error { return err } +func (m *MySQL) DatabaseExists(name string) (bool, error) { + rows, err := m.Query("SHOW DATABASES") + if err != nil { + return false, err + } + defer rows.Close() + + for rows.Next() { + var database string + if err := rows.Scan(&database); err != nil { + continue + } + if database == name { + return true, nil + } + } + return false, nil +} + +func (m *MySQL) DatabaseSize(name string) (int64, error) { + var size int64 + err := m.QueryRow(fmt.Sprintf("SELECT SUM(data_length + index_length) FROM information_schema.tables WHERE table_schema = '%s'", name)).Scan(&size) + return size, err +} + func (m *MySQL) UserCreate(user, password string) error { _, err := m.Exec(fmt.Sprintf("CREATE USER IF NOT EXISTS '%s'@'localhost' IDENTIFIED BY '%s'", user, password)) m.flushPrivileges() diff --git a/pkg/db/postgres.go b/pkg/db/postgres.go index d282deb9..85c09ebe 100644 --- a/pkg/db/postgres.go +++ b/pkg/db/postgres.go @@ -77,6 +77,23 @@ func (m *Postgres) DatabaseDrop(name string) error { return err } +func (m *Postgres) DatabaseExist(name string) (bool, error) { + var count int + if err := m.QueryRow("SELECT COUNT(*) FROM pg_database WHERE datname = $1", name).Scan(&count); err != nil { + return false, err + } + return count > 0, nil +} + +func (m *Postgres) DatabaseSize(name string) (int64, error) { + query := fmt.Sprintf("SELECT pg_database_size('%s')", name) + var size int64 + if err := m.QueryRow(query).Scan(&size); err != nil { + return 0, err + } + return size, nil +} + func (m *Postgres) UserCreate(user, password string) error { _, err := m.Exec(fmt.Sprintf("CREATE USER %s WITH PASSWORD '%s'", user, password)) if err != nil { diff --git a/pkg/io/path.go b/pkg/io/path.go index 9dbe6491..f795ae4b 100644 --- a/pkg/io/path.go +++ b/pkg/io/path.go @@ -6,6 +6,8 @@ import ( "os" "os/exec" "path/filepath" + "strconv" + "strings" ) // Remove 删除文件/目录 @@ -158,3 +160,29 @@ func IsDir(path string) bool { } return info.IsDir() } + +// SizeX 获取路径大小(du命令) +func SizeX(path string) (int64, error) { + out, err := exec.Command("du", "-sb", path).Output() + if err != nil { + return 0, err + } + + parts := strings.Fields(string(out)) + if len(parts) == 0 { + return 0, fmt.Errorf("无法解析 du 输出") + } + + return strconv.ParseInt(parts[0], 10, 64) +} + +// CountX 统计目录下文件数 +func CountX(path string) (int64, error) { + out, err := exec.Command("find", path, "-printf", ".").Output() + if err != nil { + return 0, err + } + + count := len(string(out)) + return int64(count), nil +} diff --git a/pkg/types/website.go b/pkg/types/website.go index ca43966d..b5de91cb 100644 --- a/pkg/types/website.go +++ b/pkg/types/website.go @@ -2,6 +2,7 @@ package types // WebsiteSetting 网站设置 type WebsiteSetting struct { + ID uint `json:"id"` Name string `json:"name"` Domains []string `json:"domains"` Ports []uint `json:"ports"` diff --git a/web/src/i18n/zh_CN.json b/web/src/i18n/zh_CN.json index 76a46dfa..9da8a2c2 100644 --- a/web/src/i18n/zh_CN.json +++ b/web/src/i18n/zh_CN.json @@ -47,7 +47,7 @@ "total": "累计上行 { sent } / 累计下行 { received }" }, "disk": { - "title": "磁盘", + "title": "硬盘", "current": "实时读取 { read }/s / 实时写入 { write }/s", "total": "累计读取 { read } / 累计写入 { write }" } diff --git a/web/src/views/home/IndexView.vue b/web/src/views/home/IndexView.vue index 42e09c23..6c452eb9 100644 --- a/web/src/views/home/IndexView.vue +++ b/web/src/views/home/IndexView.vue @@ -57,7 +57,7 @@ const getRealtime = async () => { netTotalRecv.value = netTotalRecvTemp netCurrentSent.value = (netTotalSent.value - netTotalSentOld) / 3 netCurrentRecv.value = (netTotalRecv.value - netTotalRecvOld) / 3 - // 计算磁盘读写 + // 计算硬盘读写 let diskTotalReadTemp = 0 let diskTotalWriteTemp = 0 let diskTotalReadOld = diskTotalRead.value diff --git a/web/src/views/website/EditView.vue b/web/src/views/website/EditView.vue index 27af5445..dd8e6da0 100644 --- a/web/src/views/website/EditView.vue +++ b/web/src/views/website/EditView.vue @@ -10,6 +10,7 @@ const route = useRoute() const { id } = route.params const setting = ref({ + if: 0, name: '', ports: [], ssl_ports: [], diff --git a/web/src/views/website/types.ts b/web/src/views/website/types.ts index fc11ef23..64c96e6a 100644 --- a/web/src/views/website/types.ts +++ b/web/src/views/website/types.ts @@ -11,6 +11,7 @@ export interface Website { } export interface WebsiteSetting { + id: number name: string ports: number[] ssl_ports: number[] @@ -36,8 +37,3 @@ export interface WebsiteSetting { raw: string log: string } - -export interface Backup { - name: string - size: string -}