diff --git a/README.md b/README.md
index 5db127d..e530a7e 100644
--- a/README.md
+++ b/README.md
@@ -26,12 +26,12 @@
- |
- |
+ Dashboard — 30-day success and failure trends, storage distribution, task volume, and cluster health. |
+ Backup tasks — schedules, multi-target policies, retention, tags, RPO goals, and recurring verification. |
- |
- |
+ Storage targets — connection health, live capacity, favourites, and redundancy roles at a glance. |
+ Backup records — success and failure states, checksums, destinations, execution logs, and retention locks. |
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 661a9b0..3c1234a 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -26,12 +26,12 @@
- |
- |
+ 仪表盘 — 30 天成功/失败趋势、存储分布、任务规模与集群健康度。 |
+ 备份任务 — 调度、双目标策略、保留规则、标签、RPO 与定时验证。 |
- |
- |
+ 存储目标 — 连接状态、实时容量、收藏及冗余角色一目了然。 |
+ 备份记录 — 成功/失败状态、校验和、落盘位置、执行日志与保留锁定。 |
diff --git a/docs-site/i18n/zh-CN/code.json b/docs-site/i18n/zh-CN/code.json
index e95c02f..c9f71ec 100644
--- a/docs-site/i18n/zh-CN/code.json
+++ b/docs-site/i18n/zh-CN/code.json
@@ -83,14 +83,18 @@
"showcase.tab.tasks": {"message": "备份任务"},
"showcase.tab.storage": {"message": "存储目标"},
"showcase.tab.nodes": {"message": "多节点"},
- "showcase.dashboard.title": {"message": "一眼掌握全局"},
- "showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录、即将触发的计划 — 一页实时数据。"},
- "showcase.tasks.title": {"message": "可视化任务编辑器"},
- "showcase.tasks.desc": {"message": "文件、MySQL、PostgreSQL、SQLite、SAP HANA — 三步完成。Cron 编辑器、多目标分发、保留策略、压缩、加密 — 点击即用。"},
- "showcase.storage.title": {"message": "70+ 后端,统一体验"},
- "showcase.storage.desc": {"message": "阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV — 加上每一种 rclone 后端。测试连接、收藏、查看实时容量。"},
- "showcase.nodes.title": {"message": "几分钟搭起 Master-Agent"},
- "showcase.nodes.desc": {"message": "创建节点、复制令牌、在任意远程主机启动 Agent。路由到节点的任务在本地执行并直接上传到存储 — 无需反向连通性。"},
+ "showcase.dashboard.alt": {"message": "BackupX 仪表盘,展示 30 天备份趋势、存储分布、任务规模和集群健康度"},
+ "showcase.dashboard.title": {"message": "一眼掌握运行态"},
+ "showcase.dashboard.desc": {"message": "在一个实时运维视图中掌握 30 天成功/失败趋势、存储分布、任务数量、数据量和最近执行记录。"},
+ "showcase.tasks.alt": {"message": "BackupX 任务列表,展示调度、存储目标、保留策略、标签、RPO 目标和验证状态"},
+ "showcase.tasks.title": {"message": "策略状态清晰可扫"},
+ "showcase.tasks.desc": {"message": "集中查看调度、多目标策略、保留规则、标签、RPO 目标和验证状态,并可一键操作任意任务。"},
+ "showcase.storage.alt": {"message": "BackupX 存储目标,展示连接状态、实时容量、收藏和冗余角色"},
+ "showcase.storage.title": {"message": "每个目标,统一视图"},
+ "showcase.storage.desc": {"message": "统一比较本地磁盘与 70+ 远程后端的连接状态、实时容量、收藏及冗余角色。"},
+ "showcase.nodes.alt": {"message": "BackupX 节点列表,展示健康度、Agent 版本、队列深度、标签和心跳时间"},
+ "showcase.nodes.title": {"message": "集群健康一屏掌握"},
+ "showcase.nodes.desc": {"message": "集中监控本地 Master 与每个远程节点的健康度、Agent 版本、队列深度、标签和心跳时间。"},
"showcase.cta": {"message": "开始阅读文档"},
"community.tag": {"message": "社区"},
diff --git a/docs-site/src/components/HomepageShowcase/index.tsx b/docs-site/src/components/HomepageShowcase/index.tsx
index 44378ab..ab0ec65 100644
--- a/docs-site/src/components/HomepageShowcase/index.tsx
+++ b/docs-site/src/components/HomepageShowcase/index.tsx
@@ -2,7 +2,7 @@ import type {ReactNode} from 'react';
import {useState} from 'react';
import clsx from 'clsx';
import Heading from '@theme/Heading';
-import Translate from '@docusaurus/Translate';
+import Translate, {translate} from '@docusaurus/Translate';
import useBaseUrl from '@docusaurus/useBaseUrl';
import Link from '@docusaurus/Link';
import styles from './styles.module.css';
@@ -11,6 +11,7 @@ type Tab = {
id: string;
label: ReactNode;
image: string;
+ imageAlt: string;
title: ReactNode;
description: ReactNode;
};
@@ -21,10 +22,14 @@ function useTabs(): Tab[] {
id: 'dashboard',
label: Dashboard,
image: useBaseUrl('/img/screenshots/dashboard.png'),
- title: Know at a glance,
+ imageAlt: translate({
+ id: 'showcase.dashboard.alt',
+ message: 'BackupX dashboard showing 30-day backup trends, storage distribution, task volume and cluster health',
+ }),
+ title: Operations at a glance,
description: (
- Backup success rates, storage usage, recent runs and upcoming schedules — all on one page with live data.
+ Track 30-day success and failure trends, storage distribution, task counts, data volume and recent runs from one live operations view.
),
},
@@ -32,10 +37,14 @@ function useTabs(): Tab[] {
id: 'tasks',
label: Backup Tasks,
image: useBaseUrl('/img/screenshots/backup-tasks.png'),
- title: Visual task editor,
+ imageAlt: translate({
+ id: 'showcase.tasks.alt',
+ message: 'BackupX task list showing schedules, storage targets, retention, tags, RPO goals and verification status',
+ }),
+ title: Policies you can scan,
description: (
- Files, MySQL, PostgreSQL, SQLite and SAP HANA with a three-step wizard. Cron editor, multi-target dispatch, retention, compression and encryption — point and click.
+ Review schedules, multi-target policies, retention, tags, RPO goals and verification status together, then act on any task in one click.
),
},
@@ -43,10 +52,14 @@ function useTabs(): Tab[] {
id: 'storage',
label: Storage Targets,
image: useBaseUrl('/img/screenshots/storage-targets.png'),
- title: 70+ backends, one flow,
+ imageAlt: translate({
+ id: 'showcase.storage.alt',
+ message: 'BackupX storage targets showing connection health, live capacity, favourites and redundancy roles',
+ }),
+ title: Every target, one view,
description: (
- Alibaba OSS, Tencent COS, S3, Google Drive, WebDAV — plus every rclone backend behind a uniform form. Test connection, favourite, and view live usage.
+ Compare connection health, live capacity, favourites and redundancy roles across local disks and 70+ remote backends.
),
},
@@ -54,10 +67,14 @@ function useTabs(): Tab[] {
id: 'nodes',
label: Multi-Node,
image: useBaseUrl('/img/screenshots/nodes.png'),
- title: Master-Agent in minutes,
+ imageAlt: translate({
+ id: 'showcase.nodes.alt',
+ message: 'BackupX node list showing health, Agent versions, queue depth, labels and heartbeat times',
+ }),
+ title: Cluster health in one view,
description: (
- Create a node, copy the token, start the Agent on any remote host. Tasks routed to a node run locally there and upload directly to storage — no reverse connectivity required.
+ Monitor health, Agent versions, queue depth, labels and heartbeat time across the local Master and every remote node.
),
},
@@ -103,7 +120,7 @@ export default function HomepageShowcase(): ReactNode {
backupx.local
-
+
{current.title}
diff --git a/docs-site/static/img/screenshots/backup-tasks.png b/docs-site/static/img/screenshots/backup-tasks.png
index 82afb4a..c975e01 100644
Binary files a/docs-site/static/img/screenshots/backup-tasks.png and b/docs-site/static/img/screenshots/backup-tasks.png differ
diff --git a/docs-site/static/img/screenshots/dashboard.png b/docs-site/static/img/screenshots/dashboard.png
index ba89f42..d646fe6 100644
Binary files a/docs-site/static/img/screenshots/dashboard.png and b/docs-site/static/img/screenshots/dashboard.png differ
diff --git a/docs-site/static/img/screenshots/nodes.png b/docs-site/static/img/screenshots/nodes.png
index baaa6dc..3705438 100644
Binary files a/docs-site/static/img/screenshots/nodes.png and b/docs-site/static/img/screenshots/nodes.png differ
diff --git a/docs-site/static/img/screenshots/storage-targets.png b/docs-site/static/img/screenshots/storage-targets.png
index 170cfb7..54a6613 100644
Binary files a/docs-site/static/img/screenshots/storage-targets.png and b/docs-site/static/img/screenshots/storage-targets.png differ
diff --git a/screenshots/backup-records.png b/screenshots/backup-records.png
index 80f7a15..a3932af 100644
Binary files a/screenshots/backup-records.png and b/screenshots/backup-records.png differ
diff --git a/screenshots/backup-tasks.png b/screenshots/backup-tasks.png
index 82afb4a..c975e01 100644
Binary files a/screenshots/backup-tasks.png and b/screenshots/backup-tasks.png differ
diff --git a/screenshots/dashboard.png b/screenshots/dashboard.png
index ba89f42..d646fe6 100644
Binary files a/screenshots/dashboard.png and b/screenshots/dashboard.png differ
diff --git a/screenshots/nodes.png b/screenshots/nodes.png
index baaa6dc..3705438 100644
Binary files a/screenshots/nodes.png and b/screenshots/nodes.png differ
diff --git a/screenshots/storage-targets.png b/screenshots/storage-targets.png
index 170cfb7..54a6613 100644
Binary files a/screenshots/storage-targets.png and b/screenshots/storage-targets.png differ
diff --git a/server/go.mod b/server/go.mod
index ffc65e0..c88ee2a 100644
--- a/server/go.mod
+++ b/server/go.mod
@@ -12,6 +12,7 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/rclone/rclone v1.73.5
github.com/robfig/cron/v3 v3.0.1
+ github.com/shirou/gopsutil/v4 v4.25.10
github.com/spf13/viper v1.20.0
go.uber.org/zap v1.27.0
golang.org/x/crypto v0.50.0
@@ -195,7 +196,6 @@ require (
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 // indirect
github.com/sagikazarmark/locafero v0.7.0 // indirect
github.com/samber/lo v1.52.0 // indirect
- github.com/shirou/gopsutil/v4 v4.25.10 // indirect
github.com/sirupsen/logrus v1.9.4-0.20230606125235-dd1b4c2e81af // indirect
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
github.com/sony/gobreaker v1.0.0 // indirect
diff --git a/server/internal/repository/backup_record_repository.go b/server/internal/repository/backup_record_repository.go
index 07f3501..9c3cdc0 100644
--- a/server/internal/repository/backup_record_repository.go
+++ b/server/internal/repository/backup_record_repository.go
@@ -59,7 +59,7 @@ func NewBackupRecordRepository(db *gorm.DB) *GormBackupRecordRepository {
func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRecordListOptions) ([]model.BackupRecord, error) {
// Omit("Manifest"):列表不需要可能很大的清单 JSON,避免每行拖出该 TEXT 列。
- query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc")
+ query := r.db.WithContext(ctx).Model(&model.BackupRecord{}).Omit("Manifest").Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").Order("started_at desc")
if options.TaskID != nil {
query = query.Where("task_id = ?", *options.TaskID)
}
@@ -87,7 +87,7 @@ func (r *GormBackupRecordRepository) List(ctx context.Context, options BackupRec
func (r *GormBackupRecordRepository) FindByID(ctx context.Context, id uint) (*model.BackupRecord, error) {
var item model.BackupRecord
- if err := r.db.WithContext(ctx).Preload("Task").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
+ if err := r.db.WithContext(ctx).Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").First(&item, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
@@ -127,7 +127,7 @@ func (r *GormBackupRecordRepository) ListRecent(ctx context.Context, limit int)
limit = 10
}
var items []model.BackupRecord
- if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
+ if err := r.db.WithContext(ctx).Omit("Manifest").Preload("Task").Preload("StorageTarget").Preload("Task.StorageTarget").Order("started_at desc").Limit(limit).Find(&items).Error; err != nil {
return nil, err
}
return items, nil
diff --git a/server/internal/repository/backup_record_repository_test.go b/server/internal/repository/backup_record_repository_test.go
index c7765e0..0b71a76 100644
--- a/server/internal/repository/backup_record_repository_test.go
+++ b/server/internal/repository/backup_record_repository_test.go
@@ -22,6 +22,11 @@ func newBackupRecordTestRepository(t *testing.T) *GormBackupRecordRepository {
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("db.DB returned error: %v", err)
+ }
+ t.Cleanup(func() { _ = sqlDB.Close() })
storageTarget := &model.StorageTarget{Name: "local", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"}
if err := db.Create(storageTarget).Error; err != nil {
t.Fatalf("seed storage target error: %v", err)
@@ -60,6 +65,9 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if stored == nil || stored.FileName != "website.tar.gz" {
t.Fatalf("unexpected stored record: %#v", stored)
}
+ if stored.StorageTarget.Name != "local" {
+ t.Fatalf("expected record storage target to be preloaded, got %#v", stored.StorageTarget)
+ }
listed, err := repo.List(ctx, BackupRecordListOptions{TaskID: &record.TaskID, Status: "success"})
if err != nil {
t.Fatalf("List returned error: %v", err)
@@ -67,6 +75,9 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if len(listed) != 1 {
t.Fatalf("expected one listed record, got %d", len(listed))
}
+ if listed[0].StorageTarget.Name != "local" {
+ t.Fatalf("expected listed storage target to be preloaded, got %#v", listed[0].StorageTarget)
+ }
recent, err := repo.ListRecent(ctx, 5)
if err != nil {
t.Fatalf("ListRecent returned error: %v", err)
@@ -74,6 +85,9 @@ func TestBackupRecordRepositoryQueries(t *testing.T) {
if len(recent) != 1 {
t.Fatalf("expected one recent record, got %d", len(recent))
}
+ if recent[0].StorageTarget.Name != "local" {
+ t.Fatalf("expected recent storage target to be preloaded, got %#v", recent[0].StorageTarget)
+ }
total, err := repo.Count(ctx)
if err != nil {
t.Fatalf("Count returned error: %v", err)
diff --git a/server/internal/service/backup_execution_service.go b/server/internal/service/backup_execution_service.go
index 76066b2..54ff04e 100644
--- a/server/internal/service/backup_execution_service.go
+++ b/server/internal/service/backup_execution_service.go
@@ -721,7 +721,8 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
return
}
defer os.RemoveAll(result.TempDir)
- // 依据运行器产出判定实际类型:产出清单 → 全量(记录清单供后续差异比对);否则为差异。
+ // 依据运行器产出判定实际类型:文件全量会产出清单;只有明确启用差异模式且
+ // 未产出清单时才是差异备份。数据库运行器本身不产出文件清单,但仍属于全量。
if result.Manifest != nil {
backupKind = model.BackupKindFull
if data, encErr := backup.EncodeManifest(*result.Manifest); encErr == nil {
@@ -729,8 +730,10 @@ func (s *BackupExecutionService) executeTask(ctx context.Context, task *model.Ba
} else {
logger.Warnf("备份清单序列化失败(不影响本次备份,但将禁用后续差异):%v", encErr)
}
- } else {
+ } else if spec.Differential {
backupKind = model.BackupKindDifferential
+ } else {
+ backupKind = model.BackupKindFull
}
finalPath := result.ArtifactPath
if strings.EqualFold(task.Compression, "gzip") && !strings.HasSuffix(strings.ToLower(finalPath), ".gz") {
diff --git a/server/internal/service/backup_execution_service_test.go b/server/internal/service/backup_execution_service_test.go
index 5f9ec15..a282445 100644
--- a/server/internal/service/backup_execution_service_test.go
+++ b/server/internal/service/backup_execution_service_test.go
@@ -107,6 +107,11 @@ func newExecutionTestServices(t *testing.T) (*BackupExecutionService, *BackupRec
if err != nil {
t.Fatalf("database.Open returned error: %v", err)
}
+ sqlDB, err := db.DB()
+ if err != nil {
+ t.Fatalf("db.DB returned error: %v", err)
+ }
+ t.Cleanup(func() { _ = sqlDB.Close() })
cipher := codec.NewConfigCipher("execution-secret")
tasks := repository.NewBackupTaskRepository(db)
targets := repository.NewStorageTargetRepository(db)
@@ -155,6 +160,34 @@ func TestBackupExecutionServiceRunTaskByIDSync(t *testing.T) {
}
}
+func TestBackupExecutionServiceSQLiteBackupRemainsFull(t *testing.T) {
+ executionService, _, tasks, _, records, sourceDir, _ := newExecutionTestServices(t)
+ dbPath := filepath.Join(sourceDir, "finance.db")
+ if err := os.WriteFile(dbPath, []byte("sqlite-demo"), 0o644); err != nil {
+ t.Fatalf("WriteFile sqlite fixture returned error: %v", err)
+ }
+ task := &model.BackupTask{
+ Name: "finance-db", Type: model.BackupTaskTypeSQLite, Enabled: true,
+ DBPath: dbPath, StorageTargetID: 1, RetentionDays: 30,
+ Compression: "none", MaxBackups: 10, LastStatus: "idle",
+ }
+ if err := tasks.Create(context.Background(), task); err != nil {
+ t.Fatalf("Create sqlite task returned error: %v", err)
+ }
+
+ detail, err := executionService.RunTaskByIDSync(context.Background(), task.ID)
+ if err != nil {
+ t.Fatalf("RunTaskByIDSync sqlite returned error: %v", err)
+ }
+ stored, err := records.FindByID(context.Background(), detail.ID)
+ if err != nil {
+ t.Fatalf("FindByID sqlite record returned error: %v", err)
+ }
+ if stored == nil || stored.BackupKind != model.BackupKindFull {
+ t.Fatalf("expected sqlite backup kind full, got %#v", stored)
+ }
+}
+
func TestBackupExecutionServiceNodePoolSelectionDoesNotPersistTaskNodeID(t *testing.T) {
executionService, _, tasks, _, records, _, _ := newExecutionTestServices(t)
ctx := context.Background()
diff --git a/server/internal/service/system_service.go b/server/internal/service/system_service.go
index 3880f55..7b23929 100644
--- a/server/internal/service/system_service.go
+++ b/server/internal/service/system_service.go
@@ -8,10 +8,11 @@ import (
"path/filepath"
"runtime"
"strings"
- "syscall"
"time"
"backupx/server/internal/config"
+
+ "github.com/shirou/gopsutil/v4/disk"
)
type SystemInfo struct {
@@ -74,11 +75,11 @@ func (s *SystemService) CheckUpdate(ctx context.Context) (*UpdateCheckResult, er
}
var release struct {
- TagName string `json:"tag_name"`
- HTMLURL string `json:"html_url"`
- Body string `json:"body"`
- Published string `json:"published_at"`
- Assets []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"`
@@ -124,12 +125,10 @@ func (s *SystemService) GetInfo(_ context.Context) *SystemInfo {
if dir == "" {
dir = "."
}
- var stat syscall.Statfs_t
- if err := syscall.Statfs(dir, &stat); err == nil {
- info.DiskTotal = int64(stat.Blocks) * int64(stat.Bsize)
- info.DiskFree = int64(stat.Bavail) * int64(stat.Bsize)
- info.DiskUsed = info.DiskTotal - info.DiskFree
+ if stat, err := disk.Usage(dir); err == nil {
+ info.DiskTotal = int64(stat.Total)
+ info.DiskFree = int64(stat.Free)
+ info.DiskUsed = int64(stat.Used)
}
return info
}
-