Compare commits

..

4 Commits

Author SHA1 Message Date
Awuqing
8eb93b3dd9 功能: Docker 一键自动更新
- 新增 POST /api/system/update-apply,执行 docker pull + docker compose up -d
- 前端系统设置页新增「一键更新(Docker)」按钮,点击后自动拉取新镜像并重启容器
- Dockerfile 安装 docker-cli + docker-cli-compose
- docker-compose.yml 挂载 /var/run/docker.sock 以支持容器内操作 Docker
- 自动检测是否为 Docker 环境,非 Docker 环境引导下载二进制
2026-04-01 23:43:12 +08:00
Wu Qing
df5c8aa80d 功能: 系统更新检查 (#31)
功能: 系统更新检查(GitHub Release + Docker)
2026-04-01 23:18:21 +08:00
Awuqing
9a4556f473 功能: 系统更新检查(GitHub Release + Docker)
后端:
- 新增 GET /api/system/update-check,从 GitHub Releases API 获取最新版本
- 自动比较当前版本与最新版本,匹配当前平台的下载链接
- 返回版本号、更新说明、下载链接、Docker 镜像信息

前端(系统设置页重构):
- 新增"检查更新"按钮,点击后展示更新结果
- 有新版本时显示版本号、更新说明、下载按钮、Docker 更新命令
- 新增磁盘状态卡片(总空间/已用/可用/使用率)
- 运行模式用彩色 Tag 区分(生产/开发)
2026-04-01 23:13:32 +08:00
Wu Qing
a772b94ca5 修复: rclone 后端列表不显示 + 调度审计 + 批量删除 (#30)
修复: rclone 后端列表不显示 + 调度审计 + 批量删除
2026-04-01 23:02:40 +08:00
7 changed files with 316 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: info?.version ?? '-' },
{ label: '运行模式', value: info?.mode ?? '-' },
{ label: '运行时长', value: formatDuration(info?.uptimeSeconds) }, { label: '运行时长', value: formatDuration(info?.uptimeSeconds) },
{ label: '启动时间', value: info?.startedAt ?? '-' }, { label: '启动时间', value: info?.startedAt ?? '-' },
{ label: '数据库路径', value: info?.databasePath ?? '-' }, { label: '数据库路径', value: <Typography.Text copyable>{info?.databasePath ?? '-'}</Typography.Text> },
]} ]} />
/>
</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 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> </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>
) )
} }

View File

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