diff --git a/README.md b/README.md index 5db127d..e530a7e 100644 --- a/README.md +++ b/README.md @@ -26,12 +26,12 @@ - - + + - - + +
DashboardBackup TasksBackupX dashboard with 30-day backup trends, storage distribution, and task health
Dashboard — 30-day success and failure trends, storage distribution, task volume, and cluster health.
BackupX task list with schedules, targets, retention, tags, RPO, and verification status
Backup tasks — schedules, multi-target policies, retention, tags, RPO goals, and recurring verification.
Storage TargetsBackup RecordsBackupX storage targets with connection health, capacity usage, and redundancy roles
Storage targets — connection health, live capacity, favourites, and redundancy roles at a glance.
BackupX backup history with success and failure states, checksums, destinations, and retention locks
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 @@ - - + + - - + +
仪表盘备份任务BackupX 仪表盘,展示 30 天备份趋势、存储分布和任务健康度
仪表盘 — 30 天成功/失败趋势、存储分布、任务规模与集群健康度。
BackupX 备份任务列表,展示调度、存储目标、保留策略、标签、RPO 和验证状态
备份任务 — 调度、双目标策略、保留规则、标签、RPO 与定时验证。
存储目标备份记录BackupX 存储目标,展示连接状态、容量使用和冗余角色
存储目标 — 连接状态、实时容量、收藏及冗余角色一目了然。
BackupX 备份历史,展示成功失败状态、校验和、存储位置及保留锁
备份记录 — 成功/失败状态、校验和、落盘位置、执行日志与保留锁定。
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.imageAlt}
{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 } -