mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-07 07:12:46 +08:00
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<UpdateCheckResult | null>(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 (
|
||||
<Space direction="vertical" size="large" style={{ width: '100%' }}>
|
||||
<PageHeader style={{ paddingBottom: 16 }} title="系统设置" subTitle="运行信息、磁盘状态与版本更新">
|
||||
@@ -105,14 +124,17 @@ export function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
<Space>
|
||||
<Button type="primary" status="success" loading={applying} onClick={handleApplyUpdate}>
|
||||
一键更新(Docker)
|
||||
</Button>
|
||||
{updateResult.downloadUrl && (
|
||||
<Link href={updateResult.downloadUrl} target="_blank">
|
||||
<Button type="primary">下载二进制包</Button>
|
||||
<Button type="outline">下载二进制包</Button>
|
||||
</Link>
|
||||
)}
|
||||
{updateResult.releaseUrl && (
|
||||
<Link href={updateResult.releaseUrl} target="_blank">
|
||||
<Button type="outline">查看 Release 详情</Button>
|
||||
<Button type="text">Release 详情</Button>
|
||||
</Link>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
@@ -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<string, string> }>('/settings')
|
||||
return response.data.data
|
||||
|
||||
Reference in New Issue
Block a user