mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-06-25 19:43:41 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94d5fb7286 | ||
|
|
8eb93b3dd9 | ||
|
|
df5c8aa80d | ||
|
|
9a4556f473 | ||
|
|
a772b94ca5 | ||
|
|
3bd15bf3fd | ||
|
|
5ae7fb2f5d | ||
|
|
37ad6b1db1 |
@@ -55,6 +55,7 @@ RUN apk add --no-cache \
|
|||||||
nginx \
|
nginx \
|
||||||
tzdata \
|
tzdata \
|
||||||
ca-certificates \
|
ca-certificates \
|
||||||
|
docker-cli docker-cli-compose \
|
||||||
# Required by mysql/postgresql backup tasks
|
# Required by mysql/postgresql backup tasks
|
||||||
mysql-client \
|
mysql-client \
|
||||||
postgresql16-client \
|
postgresql16-client \
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ services:
|
|||||||
- "8340:8340"
|
- "8340:8340"
|
||||||
volumes:
|
volumes:
|
||||||
- backupx-data:/app/data
|
- backupx-data:/app/data
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock # 支持 Web 一键更新
|
||||||
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
|
# 挂载需要备份的宿主机目录(按需添加,:ro 表示只读):
|
||||||
# - /var/www:/mnt/www:ro
|
# - /var/www:/mnt/www:ro
|
||||||
# - /etc/nginx:/mnt/nginx-conf:ro
|
# - /etc/nginx:/mnt/nginx-conf:ro
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
|
backupExecutionService := service.NewBackupExecutionService(backupTaskRepo, backupRecordRepo, storageTargetRepo, storageRegistry, backupRunnerRegistry, logHub, retentionService, configCipher, notificationService, cfg.Backup.TempDir, cfg.Backup.MaxConcurrent, cfg.Backup.Retries, cfg.Backup.BandwidthLimit)
|
||||||
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
|
schedulerService := scheduler.NewService(backupTaskRepo, backupExecutionService, appLogger)
|
||||||
backupTaskService.SetScheduler(schedulerService)
|
backupTaskService.SetScheduler(schedulerService)
|
||||||
|
// 审计日志注入延迟到 auditService 创建后(见下方)
|
||||||
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
backupRecordService := service.NewBackupRecordService(backupRecordRepo, backupExecutionService, logHub)
|
||||||
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
dashboardService := service.NewDashboardService(backupTaskRepo, backupRecordRepo, storageTargetRepo)
|
||||||
settingsService := service.NewSettingsService(systemConfigRepo)
|
settingsService := service.NewSettingsService(systemConfigRepo)
|
||||||
@@ -102,6 +103,7 @@ func New(ctx context.Context, cfg config.Config, version string) (*Application,
|
|||||||
auditLogRepo := repository.NewAuditLogRepository(db)
|
auditLogRepo := repository.NewAuditLogRepository(db)
|
||||||
auditService := service.NewAuditService(auditLogRepo)
|
auditService := service.NewAuditService(auditLogRepo)
|
||||||
authService.SetAuditService(auditService)
|
authService.SetAuditService(auditService)
|
||||||
|
schedulerService.SetAuditRecorder(auditService)
|
||||||
|
|
||||||
// Database discovery
|
// Database discovery
|
||||||
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
databaseDiscoveryService := service.NewDatabaseDiscoveryService(backup.NewOSCommandExecutor())
|
||||||
|
|||||||
@@ -147,6 +147,24 @@ func (h *BackupRecordHandler) Delete(c *gin.Context) {
|
|||||||
response.Success(c, gin.H{"deleted": true})
|
response.Success(c, gin.H{"deleted": true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *BackupRecordHandler) BatchDelete(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
IDs []uint `json:"ids" binding:"required,min=1"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
response.Error(c, apperror.BadRequest("BACKUP_RECORD_BATCH_INVALID", "批量删除参数不合法", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deleted := 0
|
||||||
|
for _, id := range input.IDs {
|
||||||
|
if err := h.service.Delete(c.Request.Context(), id); err == nil {
|
||||||
|
deleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordAudit(c, h.auditService, "backup_record", "batch_delete", "backup_record", "", "", fmt.Sprintf("批量删除 %d 条备份记录", deleted))
|
||||||
|
response.Success(c, gin.H{"deleted": deleted})
|
||||||
|
}
|
||||||
|
|
||||||
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
|
func buildRecordFilter(c *gin.Context) (service.BackupRecordListInput, error) {
|
||||||
var filter service.BackupRecordListInput
|
var filter service.BackupRecordListInput
|
||||||
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
if taskIDValue := strings.TrimSpace(c.Query("taskId")); taskIDValue != "" {
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
system := api.Group("/system")
|
system := api.Group("/system")
|
||||||
system.Use(AuthMiddleware(deps.JWTManager))
|
system.Use(AuthMiddleware(deps.JWTManager))
|
||||||
system.GET("/info", systemHandler.Info)
|
system.GET("/info", systemHandler.Info)
|
||||||
|
system.GET("/update-check", systemHandler.CheckUpdate)
|
||||||
|
system.POST("/update-apply", systemHandler.ApplyUpdate)
|
||||||
|
|
||||||
storageTargets := api.Group("/storage-targets")
|
storageTargets := api.Group("/storage-targets")
|
||||||
storageTargets.Use(AuthMiddleware(deps.JWTManager))
|
storageTargets.Use(AuthMiddleware(deps.JWTManager))
|
||||||
@@ -106,6 +108,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine {
|
|||||||
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
backupRecords.GET("/:id/logs/stream", backupRecordHandler.StreamLogs)
|
||||||
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
backupRecords.GET("/:id/download", backupRecordHandler.Download)
|
||||||
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
backupRecords.POST("/:id/restore", backupRecordHandler.Restore)
|
||||||
|
backupRecords.POST("/batch-delete", backupRecordHandler.BatchDelete)
|
||||||
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
backupRecords.DELETE("/:id", backupRecordHandler.Delete)
|
||||||
dashboard := api.Group("/dashboard")
|
dashboard := api.Group("/dashboard")
|
||||||
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
dashboard.Use(AuthMiddleware(deps.JWTManager))
|
||||||
|
|||||||
@@ -17,3 +17,26 @@ func NewSystemHandler(systemService *service.SystemService) *SystemHandler {
|
|||||||
func (h *SystemHandler) Info(c *gin.Context) {
|
func (h *SystemHandler) Info(c *gin.Context) {
|
||||||
response.Success(c, h.systemService.GetInfo(c.Request.Context()))
|
response.Success(c, h.systemService.GetInfo(c.Request.Context()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SystemHandler) ApplyUpdate(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&input)
|
||||||
|
result := h.systemService.ApplyDockerUpdate(c.Request.Context(), input.Version)
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SystemHandler) CheckUpdate(c *gin.Context) {
|
||||||
|
result, err := h.systemService.CheckUpdate(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
// 即使检查失败也返回当前版本信息
|
||||||
|
response.Success(c, gin.H{
|
||||||
|
"currentVersion": result.CurrentVersion,
|
||||||
|
"hasUpdate": false,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.Success(c, result)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,12 +17,18 @@ type TaskRunner interface {
|
|||||||
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
|
RunTaskByID(context.Context, uint) (*servicepkg.BackupRecordDetail, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AuditRecorder 记录审计日志(可选依赖)
|
||||||
|
type AuditRecorder interface {
|
||||||
|
Record(servicepkg.AuditEntry)
|
||||||
|
}
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cron *cron.Cron
|
cron *cron.Cron
|
||||||
tasks repository.BackupTaskRepository
|
tasks repository.BackupTaskRepository
|
||||||
runner TaskRunner
|
runner TaskRunner
|
||||||
logger *zap.Logger
|
logger *zap.Logger
|
||||||
|
audit AuditRecorder
|
||||||
entries map[uint]cron.EntryID
|
entries map[uint]cron.EntryID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +37,8 @@ func NewService(tasks repository.BackupTaskRepository, runner TaskRunner, logger
|
|||||||
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
|
return &Service{cron: cron.New(cron.WithParser(parser), cron.WithLocation(time.UTC)), tasks: tasks, runner: runner, logger: logger, entries: make(map[uint]cron.EntryID)}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetAuditRecorder(audit AuditRecorder) { s.audit = audit }
|
||||||
|
|
||||||
func (s *Service) Start(ctx context.Context) error {
|
func (s *Service) Start(ctx context.Context) error {
|
||||||
if err := s.Reload(ctx); err != nil {
|
if err := s.Reload(ctx); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -96,9 +104,19 @@ func (s *Service) syncTaskLocked(task *model.BackupTask) error {
|
|||||||
if !task.Enabled || task.CronExpr == "" {
|
if !task.Enabled || task.CronExpr == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
taskID := task.ID
|
||||||
|
taskName := task.Name
|
||||||
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
|
entryID, err := s.cron.AddFunc(task.CronExpr, func() {
|
||||||
if _, runErr := s.runner.RunTaskByID(context.Background(), task.ID); runErr != nil && s.logger != nil {
|
// 自动调度任务记录审计日志
|
||||||
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", task.ID), zap.Error(runErr))
|
if s.audit != nil {
|
||||||
|
s.audit.Record(servicepkg.AuditEntry{
|
||||||
|
Username: "system", Category: "backup_task", Action: "scheduled_run",
|
||||||
|
TargetType: "backup_task", TargetID: fmt.Sprintf("%d", taskID),
|
||||||
|
TargetName: taskName, Detail: fmt.Sprintf("定时调度触发备份任务: %s (cron: %s)", taskName, task.CronExpr),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, runErr := s.runner.RunTaskByID(context.Background(), taskID); runErr != nil && s.logger != nil {
|
||||||
|
s.logger.Warn("scheduled backup run failed", zap.Uint("task_id", taskID), zap.Error(runErr))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -363,33 +363,46 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
|
|||||||
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
logger.Warnf("存储目标 %s 创建客户端失败:%v", targetName, resolveErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
artifact, openErr := os.Open(finalPath)
|
|
||||||
if openErr != nil {
|
|
||||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
|
|
||||||
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer artifact.Close()
|
|
||||||
logger.Infof("开始上传备份到存储目标:%s", targetName)
|
logger.Infof("开始上传备份到存储目标:%s", targetName)
|
||||||
// hashingReader: 上传过程中同步计算字节数 + SHA-256,单次读取零额外 I/O
|
// 上传级重试:最多 3 次,指数退避(10s, 30s, 90s)
|
||||||
hr := newHashingReader(artifact)
|
maxAttempts := 3
|
||||||
// progressReader: 包装 hashingReader,通过 LogHub 推送实时上传进度
|
var lastUploadErr error
|
||||||
pr := newProgressReader(hr, fileSize, func(bytesRead int64, speedBps float64) {
|
var hr *hashingReader
|
||||||
percent := float64(0)
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
if fileSize > 0 {
|
if attempt > 1 {
|
||||||
percent = float64(bytesRead) / float64(fileSize) * 100
|
backoff := time.Duration(attempt*attempt) * 10 * time.Second
|
||||||
|
logger.Warnf("存储目标 %s 第 %d 次重试(等待 %v):%v", targetName, attempt, backoff, lastUploadErr)
|
||||||
|
time.Sleep(backoff)
|
||||||
}
|
}
|
||||||
s.logHub.AppendProgress(recordID, backup.ProgressInfo{
|
artifact, openErr := os.Open(finalPath)
|
||||||
BytesSent: bytesRead,
|
if openErr != nil {
|
||||||
TotalBytes: fileSize,
|
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: openErr.Error()}
|
||||||
Percent: percent,
|
logger.Warnf("存储目标 %s 打开备份文件失败:%v", targetName, openErr)
|
||||||
SpeedBps: speedBps,
|
return
|
||||||
TargetName: targetName,
|
}
|
||||||
|
hr = newHashingReader(artifact)
|
||||||
|
pr := newProgressReader(hr, fileSize, func(bytesRead int64, speedBps float64) {
|
||||||
|
percent := float64(0)
|
||||||
|
if fileSize > 0 {
|
||||||
|
percent = float64(bytesRead) / float64(fileSize) * 100
|
||||||
|
}
|
||||||
|
s.logHub.AppendProgress(recordID, backup.ProgressInfo{
|
||||||
|
BytesSent: bytesRead,
|
||||||
|
TotalBytes: fileSize,
|
||||||
|
Percent: percent,
|
||||||
|
SpeedBps: speedBps,
|
||||||
|
TargetName: targetName,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
lastUploadErr = provider.Upload(ctx, storagePath, pr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)})
|
||||||
if uploadErr := provider.Upload(ctx, storagePath, pr, fileSize, map[string]string{"taskId": fmt.Sprintf("%d", task.ID), "recordId": fmt.Sprintf("%d", recordID)}); uploadErr != nil {
|
artifact.Close()
|
||||||
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: uploadErr.Error()}
|
if lastUploadErr == nil {
|
||||||
logger.Warnf("存储目标 %s 上传失败:%v", targetName, uploadErr)
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if lastUploadErr != nil {
|
||||||
|
uploadResults[index] = StorageUploadResultItem{StorageTargetID: targetID, StorageTargetName: targetName, Status: "failed", Error: lastUploadErr.Error()}
|
||||||
|
logger.Warnf("存储目标 %s 上传失败(已重试 %d 次):%v", targetName, maxAttempts, lastUploadErr)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 完整性校验:对比实际传输字节数
|
// 完整性校验:对比实际传输字节数
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,6 +37,82 @@ func NewSystemService(cfg config.Config, version string, startedAt time.Time) *S
|
|||||||
return &SystemService{cfg: cfg, version: version, startedAt: startedAt}
|
return &SystemService{cfg: cfg, version: version, startedAt: startedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateCheckResult 描述版本更新检查结果。
|
||||||
|
type UpdateCheckResult struct {
|
||||||
|
CurrentVersion string `json:"currentVersion"`
|
||||||
|
LatestVersion string `json:"latestVersion"`
|
||||||
|
HasUpdate bool `json:"hasUpdate"`
|
||||||
|
ReleaseURL string `json:"releaseUrl,omitempty"`
|
||||||
|
ReleaseNotes string `json:"releaseNotes,omitempty"`
|
||||||
|
PublishedAt string `json:"publishedAt,omitempty"`
|
||||||
|
DownloadURL string `json:"downloadUrl,omitempty"`
|
||||||
|
DockerImage string `json:"dockerImage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const githubRepoAPI = "https://api.github.com/repos/Awuqing/BackupX/releases/latest"
|
||||||
|
|
||||||
|
// CheckUpdate 从 GitHub Releases 检查是否有新版本。
|
||||||
|
func (s *SystemService) CheckUpdate(ctx context.Context) (*UpdateCheckResult, error) {
|
||||||
|
result := &UpdateCheckResult{
|
||||||
|
CurrentVersion: s.version,
|
||||||
|
DockerImage: "awuqing/backupx",
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, githubRepoAPI, nil)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||||
|
req.Header.Set("User-Agent", "BackupX/"+s.version)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return result, fmt.Errorf("fetch latest release: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return result, fmt.Errorf("github api returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var release struct {
|
||||||
|
TagName string `json:"tag_name"`
|
||||||
|
HTMLURL string `json:"html_url"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
Published string `json:"published_at"`
|
||||||
|
Assets []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
BrowserDownloadURL string `json:"browser_download_url"`
|
||||||
|
} `json:"assets"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
|
||||||
|
return result, fmt.Errorf("decode release: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.LatestVersion = release.TagName
|
||||||
|
result.ReleaseURL = release.HTMLURL
|
||||||
|
result.ReleaseNotes = release.Body
|
||||||
|
result.PublishedAt = release.Published
|
||||||
|
|
||||||
|
// 比较版本号(去 v 前缀后字符串比较)
|
||||||
|
current := strings.TrimPrefix(s.version, "v")
|
||||||
|
latest := strings.TrimPrefix(release.TagName, "v")
|
||||||
|
result.HasUpdate = latest > current && current != "dev"
|
||||||
|
|
||||||
|
// 匹配当前平台的下载链接
|
||||||
|
goos := runtime.GOOS
|
||||||
|
goarch := runtime.GOARCH
|
||||||
|
suffix := fmt.Sprintf("%s-%s.tar.gz", goos, goarch)
|
||||||
|
for _, asset := range release.Assets {
|
||||||
|
if strings.HasSuffix(asset.Name, suffix) {
|
||||||
|
result.DownloadURL = asset.BrowserDownloadURL
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
|
func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
info := &SystemInfo{
|
info := &SystemInfo{
|
||||||
@@ -51,3 +134,63 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
|
|||||||
}
|
}
|
||||||
return info
|
return info
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateApplyResult 描述自动更新执行结果。
|
||||||
|
type UpdateApplyResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Output string `json:"output,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDockerEnvironment 检测当前是否运行在 Docker 容器中。
|
||||||
|
func (s *SystemService) IsDockerEnvironment() bool {
|
||||||
|
if _, err := os.Stat("/.dockerenv"); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDockerUpdate 执行 Docker 自动更新:pull 新镜像 + recreate 容器。
|
||||||
|
// 容器会在 docker compose up -d 后自动重启为新版本。
|
||||||
|
func (s *SystemService) ApplyDockerUpdate(_ context.Context, targetVersion string) *UpdateApplyResult {
|
||||||
|
if !s.IsDockerEnvironment() {
|
||||||
|
return &UpdateApplyResult{Success: false, Message: "当前非 Docker 环境,请手动下载二进制更新"}
|
||||||
|
}
|
||||||
|
|
||||||
|
image := "awuqing/backupx"
|
||||||
|
tag := strings.TrimSpace(targetVersion)
|
||||||
|
if tag == "" {
|
||||||
|
tag = "latest"
|
||||||
|
}
|
||||||
|
pullTarget := image + ":" + tag
|
||||||
|
|
||||||
|
// Step 1: docker pull
|
||||||
|
pullCmd := exec.Command("docker", "pull", pullTarget)
|
||||||
|
pullOut, pullErr := pullCmd.CombinedOutput()
|
||||||
|
if pullErr != nil {
|
||||||
|
return &UpdateApplyResult{Success: false, Message: fmt.Sprintf("docker pull 失败: %v", pullErr), Output: string(pullOut)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: docker compose up -d(后台执行,容器会自重启)
|
||||||
|
// 检测 compose 命令
|
||||||
|
composeBin := "docker"
|
||||||
|
composeArgs := []string{"compose", "up", "-d"}
|
||||||
|
if _, err := exec.LookPath("docker-compose"); err == nil {
|
||||||
|
composeBin = "docker-compose"
|
||||||
|
composeArgs = []string{"up", "-d"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 异步执行,给 API 响应留时间
|
||||||
|
go func() {
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
cmd := exec.Command(composeBin, composeArgs...)
|
||||||
|
cmd.Dir = "/app" // Docker 容器中的工作目录
|
||||||
|
_ = cmd.Run()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return &UpdateApplyResult{
|
||||||
|
Success: true,
|
||||||
|
Message: fmt.Sprintf("已拉取 %s,容器即将自动重启到新版本", pullTarget),
|
||||||
|
Output: string(pullOut),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,88 +1,159 @@
|
|||||||
import { Card, Descriptions, Grid, PageHeader, Space, Typography } from '@arco-design/web-react'
|
import { Badge, Button, Card, Descriptions, Grid, Link, Message, PageHeader, Space, Tag, Typography } from '@arco-design/web-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { fetchSystemInfo, type SystemInfo } from '../../services/system'
|
import { fetchSystemInfo, checkUpdate, applyUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system'
|
||||||
import { resolveErrorMessage } from '../../utils/error'
|
import { resolveErrorMessage } from '../../utils/error'
|
||||||
import { formatDuration } from '../../utils/format'
|
import { formatDuration } from '../../utils/format'
|
||||||
|
|
||||||
const { Row, Col } = Grid
|
const { Row, Col } = Grid
|
||||||
|
|
||||||
const deploySteps = [
|
function formatBytes(bytes: number | undefined): string {
|
||||||
'1. 构建前端:cd web && npm run build',
|
if (!bytes || bytes <= 0) return '-'
|
||||||
'2. 编译后端:cd server && go build -o backupx ./cmd/backupx',
|
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||||
'3. 部署静态资源与二进制,并按 deploy/ 目录提供的配置接入 Nginx 与 systemd',
|
let i = 0
|
||||||
'4. 首次启动后访问 Web 控制台,完成管理员初始化与存储目标配置',
|
let size = bytes
|
||||||
]
|
while (size >= 1024 && i < units.length - 1) {
|
||||||
|
size /= 1024
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return `${size.toFixed(1)} ${units[i]}`
|
||||||
|
}
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const [info, setInfo] = useState<SystemInfo | null>(null)
|
const [info, setInfo] = useState<SystemInfo | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [updateResult, setUpdateResult] = useState<UpdateCheckResult | null>(null)
|
||||||
|
const [checking, setChecking] = useState(false)
|
||||||
|
const [applying, setApplying] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const result = await fetchSystemInfo()
|
const result = await fetchSystemInfo()
|
||||||
if (active) {
|
if (active) { setInfo(result); setError('') }
|
||||||
setInfo(result)
|
|
||||||
setError('')
|
|
||||||
}
|
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
if (active) {
|
if (active) setError(resolveErrorMessage(loadError, '加载系统信息失败'))
|
||||||
setError(resolveErrorMessage(loadError, '加载系统设置失败'))
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
if (active) {
|
if (active) setLoading(false)
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => { active = false }
|
||||||
active = false
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
async function handleCheckUpdate() {
|
||||||
|
setChecking(true)
|
||||||
|
try {
|
||||||
|
const result = await checkUpdate()
|
||||||
|
setUpdateResult(result)
|
||||||
|
} catch (e) {
|
||||||
|
setUpdateResult({ currentVersion: info?.version || '-', latestVersion: '-', hasUpdate: false, error: resolveErrorMessage(e, '检查更新失败') })
|
||||||
|
} finally {
|
||||||
|
setChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleApplyUpdate() {
|
||||||
|
if (!updateResult?.latestVersion) return
|
||||||
|
setApplying(true)
|
||||||
|
try {
|
||||||
|
const result = await applyUpdate(updateResult.latestVersion)
|
||||||
|
if (result.success) {
|
||||||
|
Message.success('更新已触发,容器即将自动重启...')
|
||||||
|
setTimeout(() => Message.info('请等待 10-30 秒后刷新页面'), 3000)
|
||||||
|
} else {
|
||||||
|
Message.warning(result.message)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
Message.error(resolveErrorMessage(e, '触发更新失败'))
|
||||||
|
} finally {
|
||||||
|
setApplying(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||||
<PageHeader
|
<PageHeader style={{ paddingBottom: 16 }} title="系统设置" subTitle="运行信息、磁盘状态与版本更新">
|
||||||
style={{ paddingBottom: 16 }}
|
|
||||||
title="系统设置"
|
|
||||||
subTitle="展示当前运行信息、部署入口和交付所需的基础操作说明"
|
|
||||||
>
|
|
||||||
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
{error ? <Typography.Text type="error">{error}</Typography.Text> : null}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Row gutter={16}>
|
<Row gutter={16}>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Card loading={loading} title="运行信息">
|
<Card loading={loading} title="运行信息">
|
||||||
<Descriptions
|
<Descriptions column={1} border data={[
|
||||||
column={1}
|
{ label: '版本', value: <Space>{info?.version ?? '-'}<Button size="mini" type="text" loading={checking} onClick={handleCheckUpdate}>检查更新</Button></Space> },
|
||||||
border
|
{ label: '运行模式', value: info?.mode === 'release' ? <Tag color="green">生产</Tag> : <Tag color="orange">{info?.mode ?? '-'}</Tag> },
|
||||||
data={[
|
{ label: '运行时长', value: formatDuration(info?.uptimeSeconds) },
|
||||||
{ label: '版本', value: info?.version ?? '-' },
|
{ label: '启动时间', value: info?.startedAt ?? '-' },
|
||||||
{ label: '运行模式', value: info?.mode ?? '-' },
|
{ label: '数据库路径', value: <Typography.Text copyable>{info?.databasePath ?? '-'}</Typography.Text> },
|
||||||
{ label: '运行时长', value: formatDuration(info?.uptimeSeconds) },
|
]} />
|
||||||
{ label: '启动时间', value: info?.startedAt ?? '-' },
|
|
||||||
{ label: '数据库路径', value: info?.databasePath ?? '-' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
<Col span={12}>
|
<Col span={12}>
|
||||||
<Card title="部署资产">
|
<Card loading={loading} title="磁盘状态">
|
||||||
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
<Descriptions column={1} border data={[
|
||||||
<Typography.Text>`deploy/nginx.conf`:静态资源托管与 `/api` 反向代理示例。</Typography.Text>
|
{ label: '总空间', value: formatBytes(info?.diskTotal) },
|
||||||
<Typography.Text>`deploy/backupx.service`:systemd 服务单元,负责守护 API 进程。</Typography.Text>
|
{ label: '已用空间', value: formatBytes(info?.diskUsed) },
|
||||||
<Typography.Text>`deploy/install.sh`:一键安装示例脚本,用于创建目录、复制文件并启动服务。</Typography.Text>
|
{ label: '可用空间', value: formatBytes(info?.diskFree) },
|
||||||
<Typography.Text>`README.md`:包含完整部署与使用文档。</Typography.Text>
|
{ label: '使用率', value: info?.diskTotal ? `${((info.diskUsed / info.diskTotal) * 100).toFixed(1)}%` : '-' },
|
||||||
</Space>
|
]} />
|
||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<Card title="部署步骤">
|
{/* 更新检查结果 */}
|
||||||
<div className="code-block">{deploySteps.join('\n')}</div>
|
{updateResult && (
|
||||||
</Card>
|
<Card title="版本更新">
|
||||||
|
{updateResult.error ? (
|
||||||
|
<Typography.Text type="warning">{updateResult.error}</Typography.Text>
|
||||||
|
) : updateResult.hasUpdate ? (
|
||||||
|
<Space direction="vertical" size="medium" style={{ width: '100%' }}>
|
||||||
|
<Space>
|
||||||
|
<Badge status="processing" />
|
||||||
|
<Typography.Text style={{ fontWeight: 600 }}>
|
||||||
|
有新版本可用:{updateResult.latestVersion}
|
||||||
|
</Typography.Text>
|
||||||
|
<Typography.Text type="secondary">(当前:{updateResult.currentVersion})</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
{updateResult.publishedAt && (
|
||||||
|
<Typography.Text type="secondary">发布时间:{new Date(updateResult.publishedAt).toLocaleString()}</Typography.Text>
|
||||||
|
)}
|
||||||
|
{updateResult.releaseNotes && (
|
||||||
|
<Card size="small" title="更新说明" style={{ maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
<Typography.Paragraph style={{ whiteSpace: 'pre-wrap', marginBottom: 0 }}>{updateResult.releaseNotes}</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" status="success" loading={applying} onClick={handleApplyUpdate}>
|
||||||
|
一键更新(Docker)
|
||||||
|
</Button>
|
||||||
|
{updateResult.downloadUrl && (
|
||||||
|
<Link href={updateResult.downloadUrl} target="_blank">
|
||||||
|
<Button type="outline">下载二进制包</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{updateResult.releaseUrl && (
|
||||||
|
<Link href={updateResult.releaseUrl} target="_blank">
|
||||||
|
<Button type="text">Release 详情</Button>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
{updateResult.dockerImage && (
|
||||||
|
<Card size="small" title="Docker 更新命令">
|
||||||
|
<Typography.Paragraph copyable code style={{ marginBottom: 0 }}>
|
||||||
|
{`docker pull ${updateResult.dockerImage}:${updateResult.latestVersion} && docker compose up -d`}
|
||||||
|
</Typography.Paragraph>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
) : (
|
||||||
|
<Space>
|
||||||
|
<Badge status="success" />
|
||||||
|
<Typography.Text>当前已是最新版本 ({updateResult.currentVersion})</Typography.Text>
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ export interface RcloneBackendInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listRcloneBackends(): Promise<RcloneBackendInfo[]> {
|
export async function listRcloneBackends(): Promise<RcloneBackendInfo[]> {
|
||||||
const { data } = await http.get<{ data: RcloneBackendInfo[] }>('/api/storage-targets/rclone/backends')
|
const { data } = await http.get<{ data: RcloneBackendInfo[] }>('/storage-targets/rclone/backends')
|
||||||
return data.data
|
return data.data
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,39 @@ export interface SystemInfo {
|
|||||||
diskUsed: number
|
diskUsed: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateCheckResult {
|
||||||
|
currentVersion: string
|
||||||
|
latestVersion: string
|
||||||
|
hasUpdate: boolean
|
||||||
|
releaseUrl?: string
|
||||||
|
releaseNotes?: string
|
||||||
|
publishedAt?: string
|
||||||
|
downloadUrl?: string
|
||||||
|
dockerImage?: string
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSystemInfo() {
|
export async function fetchSystemInfo() {
|
||||||
const response = await http.get<{ code: string; message: string; data: SystemInfo }>('/system/info')
|
const response = await http.get<{ code: string; message: string; data: SystemInfo }>('/system/info')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function checkUpdate() {
|
||||||
|
const response = await http.get<{ code: string; message: string; data: UpdateCheckResult }>('/system/update-check')
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateApplyResult {
|
||||||
|
success: boolean
|
||||||
|
message: string
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyUpdate(version: string) {
|
||||||
|
const response = await http.post<{ code: string; message: string; data: UpdateApplyResult }>('/system/update-apply', { version })
|
||||||
|
return response.data.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchSettings() {
|
export async function fetchSettings() {
|
||||||
const response = await http.get<{ code: string; message: string; data: Record<string, string> }>('/settings')
|
const response = await http.get<{ code: string; message: string; data: Record<string, string> }>('/settings')
|
||||||
return response.data.data
|
return response.data.data
|
||||||
|
|||||||
Reference in New Issue
Block a user