diff --git a/Dockerfile b/Dockerfile index 0103612..02e4695 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,6 +55,7 @@ RUN apk add --no-cache \ nginx \ tzdata \ ca-certificates \ + docker-cli docker-cli-compose \ # Required by mysql/postgresql backup tasks mysql-client \ postgresql16-client \ diff --git a/docker-compose.yml b/docker-compose.yml index 7283480..3625e6f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,7 @@ services: - "8340:8340" volumes: - backupx-data:/app/data + - /var/run/docker.sock:/var/run/docker.sock # 支持 Web 一键更新 # 挂载需要备份的宿主机目录(按需添加,:ro 表示只读): # - /var/www:/mnt/www:ro # - /etc/nginx:/mnt/nginx-conf:ro diff --git a/server/internal/http/router.go b/server/internal/http/router.go index ed32909..12df699 100644 --- a/server/internal/http/router.go +++ b/server/internal/http/router.go @@ -69,6 +69,7 @@ func NewRouter(deps RouterDependencies) *gin.Engine { system.Use(AuthMiddleware(deps.JWTManager)) system.GET("/info", systemHandler.Info) system.GET("/update-check", systemHandler.CheckUpdate) + system.POST("/update-apply", systemHandler.ApplyUpdate) storageTargets := api.Group("/storage-targets") storageTargets.Use(AuthMiddleware(deps.JWTManager)) diff --git a/server/internal/http/system_handler.go b/server/internal/http/system_handler.go index 206b99b..cb723c6 100644 --- a/server/internal/http/system_handler.go +++ b/server/internal/http/system_handler.go @@ -18,6 +18,15 @@ func (h *SystemHandler) Info(c *gin.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 { diff --git a/server/internal/service/system_service.go b/server/internal/service/system_service.go index 17bc344..36307ed 100644 --- a/server/internal/service/system_service.go +++ b/server/internal/service/system_service.go @@ -5,6 +5,8 @@ import ( "encoding/json" "fmt" "net/http" + "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -132,3 +134,63 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo { } 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), + } +} diff --git a/web/src/pages/settings/SettingsPage.tsx b/web/src/pages/settings/SettingsPage.tsx index b3ccb63..0ae8665 100644 --- a/web/src/pages/settings/SettingsPage.tsx +++ b/web/src/pages/settings/SettingsPage.tsx @@ -1,6 +1,6 @@ -import { Badge, Button, Card, Descriptions, Grid, Link, PageHeader, Space, Tag, 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 { fetchSystemInfo, checkUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system' +import { fetchSystemInfo, checkUpdate, applyUpdate, type SystemInfo, type UpdateCheckResult } from '../../services/system' import { resolveErrorMessage } from '../../utils/error' import { formatDuration } from '../../utils/format' @@ -24,6 +24,7 @@ export function SettingsPage() { const [error, setError] = useState('') const [updateResult, setUpdateResult] = useState(null) const [checking, setChecking] = useState(false) + const [applying, setApplying] = useState(false) useEffect(() => { let active = true @@ -52,6 +53,24 @@ export function SettingsPage() { } } + 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 ( @@ -105,14 +124,17 @@ export function SettingsPage() { )} + {updateResult.downloadUrl && ( - + )} {updateResult.releaseUrl && ( - + )} diff --git a/web/src/services/system.ts b/web/src/services/system.ts index c2eaa45..c425cf9 100644 --- a/web/src/services/system.ts +++ b/web/src/services/system.ts @@ -33,6 +33,17 @@ export async function checkUpdate() { 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() { const response = await http.get<{ code: string; message: string; data: Record }>('/settings') return response.data.data