mirror of
https://github.com/acepanel/panel.git
synced 2026-02-04 07:57:21 +08:00
feat: 数据备份前端
This commit is contained in:
@@ -2,12 +2,23 @@ package biz
|
||||
|
||||
import "github.com/TheTNB/panel/pkg/types"
|
||||
|
||||
type BackupType string
|
||||
|
||||
const (
|
||||
BackupTypePath BackupType = "path"
|
||||
BackupTypeWebsite BackupType = "website"
|
||||
BackupTypeMySQL BackupType = "mysql"
|
||||
BackupTypePostgres BackupType = "postgres"
|
||||
BackupTypeRedis BackupType = "redis"
|
||||
BackupTypePanel BackupType = "panel"
|
||||
)
|
||||
|
||||
type BackupRepo interface {
|
||||
List(typ string) ([]*types.BackupFile, error)
|
||||
Create(typ, target string, path ...string) error
|
||||
Delete(typ, name string) error
|
||||
Restore(typ, backup, target string) error
|
||||
List(typ BackupType) ([]*types.BackupFile, error)
|
||||
Create(typ BackupType, target string, path ...string) error
|
||||
Delete(typ BackupType, name string) error
|
||||
Restore(typ BackupType, backup, target string) error
|
||||
ClearExpired(path, prefix string, save int) error
|
||||
CutoffLog(path, target string) error
|
||||
GetPath(typ string) (string, error)
|
||||
GetPath(typ BackupType) (string, error)
|
||||
}
|
||||
|
||||
@@ -34,37 +34,38 @@ func NewBackupRepo() biz.BackupRepo {
|
||||
}
|
||||
|
||||
// List 备份列表
|
||||
func (r *backupRepo) List(typ string) ([]*types.BackupFile, error) {
|
||||
backupPath, err := r.GetPath(typ)
|
||||
func (r *backupRepo) List(typ biz.BackupType) ([]*types.BackupFile, error) {
|
||||
path, err := r.GetPath(typ)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
files, err := io.ReadDir(backupPath)
|
||||
files, err := io.ReadDir(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var backupList []*types.BackupFile
|
||||
list := make([]*types.BackupFile, 0)
|
||||
for _, file := range files {
|
||||
info, err := file.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
backupList = append(backupList, &types.BackupFile{
|
||||
list = append(list, &types.BackupFile{
|
||||
Name: file.Name(),
|
||||
Path: filepath.Join(path, file.Name()),
|
||||
Size: str.FormatBytes(float64(info.Size())),
|
||||
})
|
||||
}
|
||||
|
||||
return backupList, nil
|
||||
return list, nil
|
||||
}
|
||||
|
||||
// Create 创建备份
|
||||
// typ 备份类型
|
||||
// target 目标名称
|
||||
// path 可选备份保存路径
|
||||
func (r *backupRepo) Create(typ, target string, path ...string) error {
|
||||
func (r *backupRepo) Create(typ biz.BackupType, target string, path ...string) error {
|
||||
defPath, err := r.GetPath(typ)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -74,13 +75,13 @@ func (r *backupRepo) Create(typ, target string, path ...string) error {
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "website":
|
||||
case biz.BackupTypeWebsite:
|
||||
return r.createWebsite(defPath, target)
|
||||
case "mysql":
|
||||
case biz.BackupTypeMySQL:
|
||||
return r.createMySQL(defPath, target)
|
||||
case "postgres":
|
||||
case biz.BackupTypePostgres:
|
||||
return r.createPostgres(defPath, target)
|
||||
case "panel":
|
||||
case biz.BackupTypePanel:
|
||||
return r.createPanel(defPath)
|
||||
|
||||
}
|
||||
@@ -89,7 +90,7 @@ func (r *backupRepo) Create(typ, target string, path ...string) error {
|
||||
}
|
||||
|
||||
// Delete 删除备份
|
||||
func (r *backupRepo) Delete(typ, name string) error {
|
||||
func (r *backupRepo) Delete(typ biz.BackupType, name string) error {
|
||||
path, err := r.GetPath(typ)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -103,7 +104,7 @@ func (r *backupRepo) Delete(typ, name string) error {
|
||||
// typ 备份类型
|
||||
// backup 备份压缩包,可以是绝对路径或者相对路径
|
||||
// target 目标名称
|
||||
func (r *backupRepo) Restore(typ, backup, target string) error {
|
||||
func (r *backupRepo) Restore(typ biz.BackupType, backup, target string) error {
|
||||
if !io.Exists(backup) {
|
||||
path, err := r.GetPath(typ)
|
||||
if err != nil {
|
||||
@@ -113,11 +114,11 @@ func (r *backupRepo) Restore(typ, backup, target string) error {
|
||||
}
|
||||
|
||||
switch typ {
|
||||
case "website":
|
||||
case biz.BackupTypeWebsite:
|
||||
return r.restoreWebsite(backup, target)
|
||||
case "mysql":
|
||||
case biz.BackupTypeMySQL:
|
||||
return r.restoreMySQL(backup, target)
|
||||
case "postgres":
|
||||
case biz.BackupTypePostgres:
|
||||
return r.restorePostgres(backup, target)
|
||||
}
|
||||
|
||||
@@ -189,13 +190,16 @@ func (r *backupRepo) ClearExpired(path, prefix string, save int) error {
|
||||
}
|
||||
|
||||
// GetPath 获取备份路径
|
||||
func (r *backupRepo) GetPath(typ string) (string, error) {
|
||||
func (r *backupRepo) GetPath(typ biz.BackupType) (string, error) {
|
||||
backupPath, err := r.setting.Get(biz.SettingKeyBackupPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !slices.Contains([]biz.BackupType{biz.BackupTypePath, biz.BackupTypeWebsite, biz.BackupTypeMySQL, biz.BackupTypePostgres, biz.BackupTypeRedis, biz.BackupTypePanel}, typ) {
|
||||
return "", errors.New("未知备份类型")
|
||||
}
|
||||
|
||||
backupPath = filepath.Join(backupPath, typ)
|
||||
backupPath = filepath.Join(backupPath, string(typ))
|
||||
if !io.Exists(backupPath) {
|
||||
if err = io.Mkdir(backupPath, 0644); err != nil {
|
||||
return "", err
|
||||
@@ -216,11 +220,22 @@ func (r *backupRepo) createWebsite(to string, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
var paths []string
|
||||
dirs, err := io.ReadDir(website.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, item := range dirs {
|
||||
paths = append(paths, filepath.Join(website.Path, item.Name()))
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
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 {
|
||||
if err = io.Compress(paths, backup, io.Zip); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String()))
|
||||
color.Greenln(fmt.Sprintf("|-已备份至文件:%s", backup))
|
||||
return nil
|
||||
}
|
||||
@@ -249,6 +264,7 @@ func (r *backupRepo) createMySQL(to string, name string) error {
|
||||
if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil {
|
||||
return err
|
||||
}
|
||||
start := time.Now()
|
||||
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
|
||||
@@ -264,6 +280,7 @@ func (r *backupRepo) createMySQL(to string, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String()))
|
||||
color.Greenln(fmt.Sprintf("|-已备份至文件:%s", backup+".zip"))
|
||||
return nil
|
||||
}
|
||||
@@ -285,6 +302,7 @@ func (r *backupRepo) createPostgres(to string, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
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
|
||||
@@ -297,6 +315,7 @@ func (r *backupRepo) createPostgres(to string, name string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String()))
|
||||
color.Greenln(fmt.Sprintf("|-已备份至文件:%s", backup+".zip"))
|
||||
return nil
|
||||
}
|
||||
@@ -309,6 +328,7 @@ func (r *backupRepo) createPanel(to string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
if err := io.Compress([]string{
|
||||
filepath.Join(app.Root, "panel"),
|
||||
"/usr/local/sbin/panel-cli",
|
||||
@@ -317,6 +337,7 @@ func (r *backupRepo) createPanel(to string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String()))
|
||||
color.Greenln(fmt.Sprintf("|-已备份至文件:%s", backup))
|
||||
return nil
|
||||
}
|
||||
|
||||
22
internal/http/request/backup.go
Normal file
22
internal/http/request/backup.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package request
|
||||
|
||||
type BackupList struct {
|
||||
Type string `json:"type" form:"type" validate:"required,oneof=path website mysql postgres redis panel"`
|
||||
}
|
||||
|
||||
type BackupCreate struct {
|
||||
Type string `json:"type" form:"type" validate:"required,oneof=website mysql postgres redis panel"`
|
||||
Target string `json:"target" form:"target" validate:"required"`
|
||||
Path string `json:"path" form:"path"`
|
||||
}
|
||||
|
||||
type BackupFile struct {
|
||||
Type string `json:"type" form:"type" validate:"required,oneof=website mysql postgres redis panel"`
|
||||
File string `json:"file" form:"file" validate:"required"`
|
||||
}
|
||||
|
||||
type BackupRestore struct {
|
||||
Type string `json:"type" form:"type" validate:"required,oneof=website mysql postgres redis panel"`
|
||||
File string `json:"file" form:"file" validate:"required"`
|
||||
Target string `json:"target" form:"target" validate:"required"`
|
||||
}
|
||||
@@ -14,11 +14,6 @@ type FileSave struct {
|
||||
Content string `form:"content" json:"content"`
|
||||
}
|
||||
|
||||
type FileUpload struct {
|
||||
Path string `json:"path" form:"path"`
|
||||
File []byte `json:"file" form:"file"`
|
||||
}
|
||||
|
||||
type FileMove struct {
|
||||
Source string `form:"source" json:"source"`
|
||||
Target string `form:"target" json:"target"`
|
||||
|
||||
@@ -35,7 +35,7 @@ func (receiver *PanelTask) Run() {
|
||||
}
|
||||
|
||||
// 备份面板
|
||||
if err := receiver.backupRepo.Create("panel", ""); err != nil {
|
||||
if err := receiver.backupRepo.Create(biz.BackupTypePanel, ""); err != nil {
|
||||
app.Logger.Error("备份面板失败", zap.Error(err))
|
||||
}
|
||||
|
||||
|
||||
@@ -62,16 +62,14 @@ func Http(r chi.Router) {
|
||||
r.Post("/{id}/status", website.UpdateStatus)
|
||||
})
|
||||
|
||||
// TODO
|
||||
r.Route("/backup", func(r chi.Router) {
|
||||
r.Use(middleware.MustLogin)
|
||||
backup := service.NewBackupService()
|
||||
r.Get("/backup", backup.List)
|
||||
r.Post("/create", backup.Create)
|
||||
r.Post("/update", backup.Update)
|
||||
r.Get("/{id}", backup.Get)
|
||||
r.Delete("/{id}", backup.Delete)
|
||||
r.Delete("/{id}/restore", backup.Restore)
|
||||
r.Get("/{type}", backup.List)
|
||||
r.Post("/{type}", backup.Create)
|
||||
r.Post("/{type}/upload", backup.Upload)
|
||||
r.Delete("/{type}/delete", backup.Delete)
|
||||
r.Post("/{type}/restore", backup.Restore)
|
||||
})
|
||||
|
||||
r.Route("/cert", func(r chi.Router) {
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
stdio "io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-rat/chix"
|
||||
|
||||
"github.com/TheTNB/panel/internal/biz"
|
||||
"github.com/TheTNB/panel/internal/data"
|
||||
"github.com/TheTNB/panel/internal/http/request"
|
||||
"github.com/TheTNB/panel/pkg/io"
|
||||
)
|
||||
|
||||
type BackupService struct {
|
||||
@@ -18,25 +25,98 @@ func NewBackupService() *BackupService {
|
||||
}
|
||||
|
||||
func (s *BackupService) List(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.BackupList](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
list, _ := s.backupRepo.List(biz.BackupType(req.Type))
|
||||
paged, total := Paginate(r, list)
|
||||
|
||||
Success(w, chix.M{
|
||||
"total": total,
|
||||
"items": paged,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *BackupService) Create(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.BackupCreate](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.backupRepo.Create(biz.BackupType(req.Type), req.Target, req.Path); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
func (s *BackupService) Update(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *BackupService) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(2 << 30); err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
}
|
||||
_, handler, err := r.FormFile("file")
|
||||
path, err := s.backupRepo.GetPath(biz.BackupType(r.FormValue("type")))
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
func (s *BackupService) Get(w http.ResponseWriter, r *http.Request) {
|
||||
if !io.Exists(filepath.Dir(path)) {
|
||||
if err = io.Mkdir(filepath.Dir(path), 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "创建文件夹失败:%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
src, _ := handler.Open()
|
||||
out, err := os.OpenFile(filepath.Join(path, handler.Filename), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "打开文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = stdio.Copy(out, src); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "写入文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = src.Close()
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
func (s *BackupService) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.BackupFile](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.backupRepo.Delete(biz.BackupType(req.Type), req.File); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
func (s *BackupService) Restore(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.BackupRestore](r)
|
||||
if err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = s.backupRepo.Restore(biz.BackupType(req.Type), req.File, req.Target); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
@@ -421,7 +421,7 @@ func (s *CliService) BackupWebsite(ctx context.Context, cmd *cli.Command) error
|
||||
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 {
|
||||
if err := s.backupRepo.Create(biz.BackupTypeWebsite, cmd.String("name"), cmd.String("path")); err != nil {
|
||||
return fmt.Errorf("|-备份失败:%v", err)
|
||||
}
|
||||
color.Greenln(s.hr)
|
||||
@@ -437,7 +437,7 @@ func (s *CliService) BackupDatabase(ctx context.Context, cmd *cli.Command) error
|
||||
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 {
|
||||
if err := s.backupRepo.Create(biz.BackupType(cmd.String("type")), cmd.String("name"), cmd.String("path")); err != nil {
|
||||
return fmt.Errorf("|-备份失败:%v", err)
|
||||
}
|
||||
color.Greenln(s.hr)
|
||||
@@ -451,7 +451,7 @@ func (s *CliService) BackupPanel(ctx context.Context, cmd *cli.Command) error {
|
||||
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 {
|
||||
if err := s.backupRepo.Create(biz.BackupTypePanel, "", cmd.String("path")); err != nil {
|
||||
return fmt.Errorf("|-备份失败:%v", err)
|
||||
}
|
||||
color.Greenln(s.hr)
|
||||
@@ -461,7 +461,7 @@ func (s *CliService) BackupPanel(ctx context.Context, cmd *cli.Command) error {
|
||||
}
|
||||
|
||||
func (s *CliService) BackupClear(ctx context.Context, cmd *cli.Command) error {
|
||||
path, err := s.backupRepo.GetPath(cmd.String("type"))
|
||||
path, err := s.backupRepo.GetPath(biz.BackupType(cmd.String("type")))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
stdio "io"
|
||||
"net/http"
|
||||
stdos "os"
|
||||
"path/filepath"
|
||||
@@ -119,18 +120,43 @@ func (s *FileService) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *FileService) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.FileUpload](r)
|
||||
if err := r.ParseMultipartForm(2 << 30); err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.FormValue("path")
|
||||
_, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = io.Write(req.Path, string(req.File), 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
if io.Exists(path) {
|
||||
Error(w, http.StatusForbidden, "目标路径%s已存在", path)
|
||||
return
|
||||
}
|
||||
|
||||
s.setPermission(req.Path, 0755, "www", "www")
|
||||
if !io.Exists(filepath.Dir(path)) {
|
||||
if err = io.Mkdir(filepath.Dir(path), 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "创建文件夹失败:%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
src, _ := handler.Open()
|
||||
out, err := stdos.OpenFile(path, stdos.O_CREATE|stdos.O_RDWR|stdos.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "打开文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = stdio.Copy(out, src); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "写入文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = src.Close()
|
||||
s.setPermission(path, 0755, "www", "www")
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
stdio "io"
|
||||
"net/http"
|
||||
stdos "os"
|
||||
"path/filepath"
|
||||
@@ -119,18 +120,43 @@ func (s *FileService) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *FileService) Upload(w http.ResponseWriter, r *http.Request) {
|
||||
req, err := Bind[request.FileUpload](r)
|
||||
if err := r.ParseMultipartForm(2 << 30); err != nil {
|
||||
Error(w, http.StatusUnprocessableEntity, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.FormValue("path")
|
||||
_, handler, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if err = io.Write(req.Path, string(req.File), 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "%v", err)
|
||||
if io.Exists(path) {
|
||||
Error(w, http.StatusForbidden, "目标路径%s已存在", path)
|
||||
return
|
||||
}
|
||||
|
||||
s.setPermission(req.Path, 0755, "www", "www")
|
||||
if !io.Exists(filepath.Dir(path)) {
|
||||
if err = io.Mkdir(filepath.Dir(path), 0755); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "创建文件夹失败:%v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
src, _ := handler.Open()
|
||||
out, err := stdos.OpenFile(path, stdos.O_CREATE|stdos.O_RDWR|stdos.O_TRUNC, 0644)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "打开文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
if _, err = stdio.Copy(out, src); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "写入文件失败:%v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = src.Close()
|
||||
s.setPermission(path, 0755, "www", "www")
|
||||
Success(w, nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@ package types
|
||||
|
||||
type BackupFile struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Size string `json:"size"`
|
||||
}
|
||||
|
||||
24
web/src/api/panel/backup/index.ts
Normal file
24
web/src/api/panel/backup/index.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
import { request } from '@/utils'
|
||||
|
||||
export default {
|
||||
// 获取备份列表
|
||||
list: (type: string, page: number, limit: number): Promise<AxiosResponse<any>> =>
|
||||
request.get(`/backup/${type}`, { params: { page, limit } }),
|
||||
// 创建备份
|
||||
create: (type: string, target: string, path: string): Promise<AxiosResponse<any>> =>
|
||||
request.post(`/backup/${type}`, { target, path }),
|
||||
// 上传备份
|
||||
upload: (type: string, formData: FormData): Promise<AxiosResponse<any>> => {
|
||||
return request.post(`/backup/${type}/upload`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
})
|
||||
},
|
||||
// 删除备份
|
||||
delete: (type: string, file: string): Promise<AxiosResponse<any>> =>
|
||||
request.delete(`/backup/${type}/delete`, { params: { file } }),
|
||||
// 恢复备份
|
||||
restore: (type: string, file: string, target: string): Promise<AxiosResponse<any>> =>
|
||||
request.post(`/backup/${type}/restore`, { file, target })
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/app',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 8
|
||||
order: 90
|
||||
},
|
||||
children: [
|
||||
{
|
||||
@@ -16,7 +16,7 @@ export default {
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: 'appIndex.title',
|
||||
icon: 'mdi:puzzle-outline',
|
||||
icon: 'mdi:apps',
|
||||
role: ['admin'],
|
||||
requireAuth: true
|
||||
}
|
||||
|
||||
@@ -300,8 +300,8 @@ onMounted(() => {
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</common-page>
|
||||
<n-modal v-model:show="addUserModal" title="新建用户">
|
||||
<n-card closable @close="() => (addUserModal = false)" title="新建用户" style="width: 60vw">
|
||||
<n-modal v-model:show="addUserModal" title="创建用户">
|
||||
<n-card closable @close="() => (addUserModal = false)" title="创建用户" style="width: 60vw">
|
||||
<n-form :model="addUserModel">
|
||||
<n-form-item path="username" label="用户名">
|
||||
<n-input
|
||||
|
||||
87
web/src/views/backup/IndexView.vue
Normal file
87
web/src/views/backup/IndexView.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
import backup from '@/api/panel/backup'
|
||||
import ListView from '@/views/backup/ListView.vue'
|
||||
import { NButton, NInput } from 'naive-ui'
|
||||
|
||||
const currentTab = ref('website')
|
||||
const createModal = ref(false)
|
||||
const createModel = ref({
|
||||
target: '',
|
||||
path: ''
|
||||
})
|
||||
const oldTab = ref('')
|
||||
|
||||
const handleCreate = () => {
|
||||
backup.create(currentTab.value, createModel.value.target, createModel.value.path).then(() => {
|
||||
createModal.value = false
|
||||
window.$message.success('创建成功')
|
||||
// 有点low,但是没找到更好的办法
|
||||
oldTab.value = currentTab.value
|
||||
currentTab.value = ''
|
||||
setTimeout(() => {
|
||||
currentTab.value = oldTab.value
|
||||
}, 0)
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<common-page show-footer>
|
||||
<template #action>
|
||||
<div flex items-center>
|
||||
<n-button class="ml-16" type="primary" @click="createModal = true">
|
||||
<TheIcon :size="18" class="mr-5" icon="material-symbols:add" />
|
||||
创建备份
|
||||
</n-button>
|
||||
</div>
|
||||
</template>
|
||||
<n-flex vertical>
|
||||
<n-alert type="info">此处仅显示面板默认备份目录的文件。</n-alert>
|
||||
<n-tabs v-model:value="currentTab" type="line" animated>
|
||||
<n-tab-pane name="website" tab="网站">
|
||||
<list-view v-model:type="currentTab" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="mysql" tab="MySQL">
|
||||
<list-view v-model:type="currentTab" />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="postgres" tab="PostgreSQL">
|
||||
<list-view v-model:type="currentTab" />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</n-flex>
|
||||
</common-page>
|
||||
<n-modal
|
||||
v-model:show="createModal"
|
||||
preset="card"
|
||||
title="创建备份"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="createModal = false"
|
||||
>
|
||||
<n-form :model="createModel">
|
||||
<n-form-item path="name" label="名称">
|
||||
<n-input
|
||||
v-model:value="createModel.target"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
placeholder="输入网站/数据库名称"
|
||||
/>
|
||||
</n-form-item>
|
||||
<n-form-item path="path" label="目录">
|
||||
<n-input
|
||||
v-model:value="createModel.path"
|
||||
type="text"
|
||||
@keydown.enter.prevent
|
||||
placeholder="留空使用默认路径"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-row :gutter="[0, 24]">
|
||||
<n-col :span="24">
|
||||
<n-button type="info" block @click="handleCreate">提交</n-button>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-modal>
|
||||
</template>
|
||||
165
web/src/views/backup/ListView.vue
Normal file
165
web/src/views/backup/ListView.vue
Normal file
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import backup from '@/api/panel/backup'
|
||||
import { renderIcon } from '@/utils'
|
||||
import type { MessageReactive } from 'naive-ui'
|
||||
import { NButton, NInput, NPopconfirm } from 'naive-ui'
|
||||
|
||||
import type { Backup } from './types'
|
||||
|
||||
const type = defineModel<string>('type', { type: String, required: true })
|
||||
|
||||
let messageReactive: MessageReactive | null = null
|
||||
|
||||
const restoreModal = ref(false)
|
||||
const restoreModel = ref({
|
||||
file: '',
|
||||
target: ''
|
||||
})
|
||||
|
||||
const columns: any = [
|
||||
{ title: '文件名', key: 'name', fixed: 'left', resizable: true, ellipsis: { tooltip: true } },
|
||||
{ title: '大小', key: 'size', width: 200, ellipsis: { tooltip: true } },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
hideInExcel: true,
|
||||
render(row: any) {
|
||||
return [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'warning',
|
||||
secondary: true,
|
||||
onClick: () => {
|
||||
restoreModel.value.file = row.path
|
||||
restoreModal.value = true
|
||||
}
|
||||
},
|
||||
{
|
||||
default: () => '恢复',
|
||||
icon: renderIcon('material-symbols:settings-backup-restore-rounded', { size: 14 })
|
||||
}
|
||||
),
|
||||
h(
|
||||
NPopconfirm,
|
||||
{
|
||||
onPositiveClick: () => handleDelete(row.name)
|
||||
},
|
||||
{
|
||||
default: () => {
|
||||
return '确定删除备份吗?'
|
||||
},
|
||||
trigger: () => {
|
||||
return h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'error',
|
||||
style: 'margin-left: 15px;'
|
||||
},
|
||||
{
|
||||
default: () => '删除',
|
||||
icon: renderIcon('material-symbols:delete-outline', { size: 14 })
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const data = ref<Backup[]>([])
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageCount: 1,
|
||||
pageSize: 10,
|
||||
itemCount: 0,
|
||||
showQuickJumper: true,
|
||||
showSizePicker: true,
|
||||
pageSizes: [10, 20, 50, 100]
|
||||
})
|
||||
|
||||
const getList = async (page: number, limit: number) => {
|
||||
const { data } = await backup.list(type.value, page, limit)
|
||||
return data
|
||||
}
|
||||
|
||||
const onPageChange = (page: number) => {
|
||||
pagination.page = page
|
||||
getList(page, pagination.pageSize).then((res) => {
|
||||
data.value = res.items
|
||||
pagination.itemCount = res.total
|
||||
pagination.pageCount = res.total / pagination.pageSize + 1
|
||||
})
|
||||
}
|
||||
|
||||
const onPageSizeChange = (pageSize: number) => {
|
||||
pagination.pageSize = pageSize
|
||||
onPageChange(1)
|
||||
}
|
||||
|
||||
const handleRestore = async () => {
|
||||
messageReactive = window.$message.loading('恢复中...', {
|
||||
duration: 0
|
||||
})
|
||||
await backup.restore(type.value, restoreModel.value.file, restoreModel.value.target).then(() => {
|
||||
messageReactive?.destroy()
|
||||
window.$message.success('恢复成功')
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
}
|
||||
|
||||
const handleDelete = async (file: string) => {
|
||||
await backup.delete(type.value, file).then(() => {
|
||||
window.$message.success('删除成功')
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onPageChange(pagination.page)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<n-data-table
|
||||
striped
|
||||
remote
|
||||
:loading="false"
|
||||
:columns="columns"
|
||||
:data="data"
|
||||
:row-key="(row: any) => row.name"
|
||||
@update:page="onPageChange"
|
||||
@update:page-size="onPageSizeChange"
|
||||
/>
|
||||
<n-modal
|
||||
v-model:show="restoreModal"
|
||||
preset="card"
|
||||
title="恢复备份"
|
||||
style="width: 60vw"
|
||||
size="huge"
|
||||
:bordered="false"
|
||||
:segmented="false"
|
||||
@close="restoreModal = false"
|
||||
>
|
||||
<n-form :model="restoreModel">
|
||||
<n-form-item path="name" label="恢复目标">
|
||||
<n-input v-model:value="restoreModel.target" type="text" @keydown.enter.prevent />
|
||||
</n-form-item>
|
||||
</n-form>
|
||||
<n-row :gutter="[0, 24]">
|
||||
<n-col :span="24">
|
||||
<n-button type="info" block @click="handleRestore">提交</n-button>
|
||||
</n-col>
|
||||
</n-row>
|
||||
</n-modal>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss"></style>
|
||||
25
web/src/views/backup/route.ts
Normal file
25
web/src/views/backup/route.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { RouteType } from '~/types/router'
|
||||
|
||||
const Layout = () => import('@/layout/IndexView.vue')
|
||||
|
||||
export default {
|
||||
name: 'backup',
|
||||
path: '/backup',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 60
|
||||
},
|
||||
children: [
|
||||
{
|
||||
name: 'backup-index',
|
||||
path: '',
|
||||
component: () => import('./IndexView.vue'),
|
||||
meta: {
|
||||
title: '数据备份',
|
||||
icon: 'mdi:backup-outline',
|
||||
role: ['admin'],
|
||||
requireAuth: true
|
||||
}
|
||||
}
|
||||
]
|
||||
} as RouteType
|
||||
5
web/src/views/backup/types.ts
Normal file
5
web/src/views/backup/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface Backup {
|
||||
name: string
|
||||
path: string
|
||||
size: string
|
||||
}
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/cert',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 2
|
||||
order: 10
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -11,16 +11,16 @@ const currentTab = ref('container')
|
||||
<common-page show-footer>
|
||||
<n-tabs v-model:value="currentTab" type="line" animated size="large">
|
||||
<n-tab-pane name="container" tab="容器">
|
||||
<ContainerView />
|
||||
<container-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="image" tab="镜像">
|
||||
<ImageView />
|
||||
<image-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="network" tab="网络">
|
||||
<NetworkView />
|
||||
<network-view />
|
||||
</n-tab-pane>
|
||||
<n-tab-pane name="volume" tab="卷">
|
||||
<VolumeView />
|
||||
<volume-view />
|
||||
</n-tab-pane>
|
||||
</n-tabs>
|
||||
</common-page>
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/container',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 5
|
||||
order: 40
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -329,7 +329,7 @@ onMounted(() => {
|
||||
<n-radio-group v-model:value="addModel.backup_type">
|
||||
<n-radio value="website">网站目录</n-radio>
|
||||
<n-radio value="mysql" :disabled="!mySQLInstalled"> MySQL 数据库</n-radio>
|
||||
<n-radio value="postgresql" :disabled="!postgreSQLInstalled">
|
||||
<n-radio value="postgres" :disabled="!postgreSQLInstalled">
|
||||
PostgreSQL 数据库
|
||||
</n-radio>
|
||||
</n-radio-group>
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/cron',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 5
|
||||
order: 70
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -127,7 +127,7 @@ const columns: DataTableColumns<RowData> = [
|
||||
selected.value = [row.full]
|
||||
compress.value = true
|
||||
} else {
|
||||
window.open('/api/panel/file/download?path=' + encodeURIComponent(row.full))
|
||||
window.open('/api/file/download?path=' + encodeURIComponent(row.full))
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/file',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 6
|
||||
order: 50
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/monitor',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 3
|
||||
order: 20
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/safe',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 4
|
||||
order: 30
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/setting',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 10
|
||||
order: 999
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/ssh',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 7
|
||||
order: 80
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,7 @@ export default {
|
||||
path: '/task',
|
||||
component: Layout,
|
||||
meta: {
|
||||
order: 9
|
||||
order: 100
|
||||
},
|
||||
children: [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user