2
0
mirror of https://github.com/acepanel/panel.git synced 2026-02-04 06:47:20 +08:00

refactor: 备份重构

This commit is contained in:
耗子
2024-10-13 17:43:36 +08:00
parent bf9c0bc12e
commit 5668bc1afd
18 changed files with 967 additions and 76 deletions

View File

@@ -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量"},

View File

@@ -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
}

12
internal/biz/backup.go Normal file
View File

@@ -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)
}

466
internal/data/backup.go Normal file
View File

@@ -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
}

View File

@@ -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)

View File

@@ -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

View File

@@ -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))
}

View File

@@ -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: "切割目录(不填则默认路径)",
},
},
},
},
},

View File

@@ -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) {

View File

@@ -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()
}

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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"`

View File

@@ -47,7 +47,7 @@
"total": "累计上行 { sent } / 累计下行 { received }"
},
"disk": {
"title": "盘",
"title": "盘",
"current": "实时读取 { read }/s / 实时写入 { write }/s",
"total": "累计读取 { read } / 累计写入 { write }"
}

View File

@@ -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

View File

@@ -10,6 +10,7 @@ const route = useRoute()
const { id } = route.params
const setting = ref<WebsiteSetting>({
if: 0,
name: '',
ports: [],
ssl_ports: [],

View File

@@ -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
}