diff --git a/README.md b/README.md index b6e23b6..4e76f36 100644 --- a/README.md +++ b/README.md @@ -28,12 +28,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 f7e368f..0adb5a0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -28,12 +28,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 0d158c3..3ca7c4a 100644 --- a/docs-site/i18n/zh-CN/code.json +++ b/docs-site/i18n/zh-CN/code.json @@ -47,18 +47,18 @@ "showcase.tab.tasks": {"message": "备份任务"}, "showcase.tab.storage": {"message": "存储目标"}, "showcase.tab.nodes": {"message": "多节点"}, - "showcase.dashboard.alt": {"message": "显示备份健康和存储使用情况的 BackupX 仪表盘"}, - "showcase.dashboard.title": {"message": "一眼掌握全局"}, - "showcase.dashboard.desc": {"message": "备份成功率、存储使用量、最近执行记录和即将触发的计划集中显示在一个实时页面。"}, - "showcase.tasks.alt": {"message": "BackupX 备份任务管理界面"}, - "showcase.tasks.title": {"message": "可视化任务编辑器"}, - "showcase.tasks.desc": {"message": "通过三步向导配置文件、MySQL、PostgreSQL、SQLite 和 SAP HANA,并绑定调度、多目标、保留、压缩和加密。"}, - "showcase.storage.alt": {"message": "BackupX 存储目标管理界面"}, - "showcase.storage.title": {"message": "多种后端,统一流程"}, - "showcase.storage.desc": {"message": "以一致表单管理阿里云 OSS、腾讯云 COS、S3、Google Drive、WebDAV 及 rclone 后端,并测试连接和查看容量。"}, - "showcase.nodes.alt": {"message": "BackupX 远程节点管理界面"}, - "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 04e6149..21ba213 100644 --- a/docs-site/src/components/HomepageShowcase/index.tsx +++ b/docs-site/src/components/HomepageShowcase/index.tsx @@ -25,11 +25,14 @@ function useTabs(): Tab[] { label: Dashboard, icon: 'monitor', image: useBaseUrl('/img/screenshots/dashboard.png'), - imageAlt: translate({id: 'showcase.dashboard.alt', message: 'BackupX dashboard showing backup health and storage usage'}), - 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. ), }, @@ -38,11 +41,14 @@ function useTabs(): Tab[] { label: Backup Tasks, icon: 'database', image: useBaseUrl('/img/screenshots/backup-tasks.png'), - imageAlt: translate({id: 'showcase.tasks.alt', message: 'BackupX backup task management screen'}), - 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. ), }, @@ -51,11 +57,14 @@ function useTabs(): Tab[] { label: Storage Targets, icon: 'storage', image: useBaseUrl('/img/screenshots/storage-targets.png'), - imageAlt: translate({id: 'showcase.storage.alt', message: 'BackupX storage target management screen'}), - 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. ), }, @@ -64,11 +73,14 @@ function useTabs(): Tab[] { label: Multi-Node, icon: 'network', image: useBaseUrl('/img/screenshots/nodes.png'), - imageAlt: translate({id: 'showcase.nodes.alt', message: 'BackupX remote node management screen'}), - 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. ), }, 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..8154fd9 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 } @@ -115,7 +115,9 @@ func (r *GormBackupRecordRepository) Create(ctx context.Context, item *model.Bac } func (r *GormBackupRecordRepository) Update(ctx context.Context, item *model.BackupRecord) error { - return r.db.WithContext(ctx).Save(item).Error + // Task 与 StorageTarget 是查询时预加载的只读关联。更新记录字段时忽略它们, + // 避免已加载的旧关联把刚修改的外键(例如首个成功上传目标)覆盖回去。 + return r.db.WithContext(ctx).Omit("Task", "StorageTarget").Save(item).Error } func (r *GormBackupRecordRepository) Delete(ctx context.Context, id uint) error { @@ -127,7 +129,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 48cfb55..0c1c0b1 100644 --- a/server/internal/repository/backup_record_repository_test.go +++ b/server/internal/repository/backup_record_repository_test.go @@ -65,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) @@ -72,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) @@ -79,6 +85,24 @@ 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) + } + secondTarget := &model.StorageTarget{Name: "secondary", Type: "local_disk", Enabled: true, ConfigCiphertext: "{}", ConfigVersion: 1, LastTestStatus: "unknown"} + if err := repo.db.Create(secondTarget).Error; err != nil { + t.Fatalf("seed second storage target error: %v", err) + } + stored.StorageTargetID = secondTarget.ID + if err := repo.Update(ctx, stored); err != nil { + t.Fatalf("Update storage target ID returned error: %v", err) + } + updated, err := repo.FindByID(ctx, record.ID) + if err != nil { + t.Fatalf("FindByID after update returned error: %v", err) + } + if updated == nil || updated.StorageTargetID != secondTarget.ID || updated.StorageTarget.Name != "secondary" { + t.Fatalf("expected updated storage target to remain %d, got %#v", secondTarget.ID, updated) + } 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 f880682..1e31bef 100644 --- a/server/internal/service/backup_execution_service.go +++ b/server/internal/service/backup_execution_service.go @@ -1026,7 +1026,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 { @@ -1034,8 +1035,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 f9eb3b5..9a1900c 100644 --- a/server/internal/service/backup_execution_service_test.go +++ b/server/internal/service/backup_execution_service_test.go @@ -108,6 +108,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) @@ -156,6 +161,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 TestBackupExecutionServiceRepositoryModeRoundTrip(t *testing.T) { executionService, recordService, tasks, _, records, sourceDir, storageDir := newExecutionTestServices(t) ctx := context.Background() diff --git a/server/internal/service/system_service.go b/server/internal/service/system_service.go index 891ff26..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 { @@ -124,11 +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 }