diff --git a/docs-site/docs/features/storage-backends.md b/docs-site/docs/features/storage-backends.md index 6e977cb..22a0b11 100644 --- a/docs-site/docs/features/storage-backends.md +++ b/docs-site/docs/features/storage-backends.md @@ -23,6 +23,12 @@ BackupX aims to accept any place you'd want to drop a backup file. New local-disk targets enable **Relay remote backups through Master** by default. This makes the configured path belong to the Master, so a storage server mounted there can collect backups from many source Agents. Turn the switch off when the path intentionally belongs to each Agent. Existing targets retain their previous Agent-local behavior until explicitly changed. +## Storage capacity and backup quotas + +Physical storage usage is shown only when the backend returns total capacity and used bytes. S3-compatible storage, including MinIO, does not provide this through the standard S3 API or rclone About capability. Missing capacity is not treated as zero. + +For these targets, BackupX displays the size tracked by backup records. Set a **backup quota** on the storage target to display a backup quota percentage. This soft quota is enforced by BackupX; it is not the bucket quota or the total disk capacity of the MinIO cluster. The tracked size does not include objects created by other applications or untracked object versions. Use the MinIO console or its monitoring interfaces for physical cluster and bucket capacity. + ## Rclone backends Every [rclone backend](https://rclone.org/overview/) is exposed as a first-class storage type — SFTP, Azure Blob, Dropbox, OneDrive, Backblaze B2, Wasabi, pCloud, HDFS, and many more. diff --git a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md index b3f7895..3233117 100644 --- a/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md +++ b/docs-site/i18n/zh-CN/docusaurus-plugin-content-docs/current/features/storage-backends.md @@ -23,6 +23,12 @@ BackupX 的目标是接入任何你想放置备份文件的地方。 新建本地磁盘目标默认开启 **远程备份经 Master 中转**。开启时,配置目录属于 Master,挂载到 Master 的存储服务器可集中接收多台源 Agent 的备份;如果该路径本就属于各 Agent,请关闭此选项。升级前已有目标保持原来的 Agent 本机落盘行为,只有显式开启后才会切换。 +## 存储容量与备份配额 + +只有后端返回总容量和已用字节时,才显示真实存储空间使用率。包括 MinIO 在内的 S3 兼容存储不通过标准 S3 API 或 rclone About 能力提供这些数据;未获取到容量不代表容量为零。 + +此类目标会显示备份记录统计的大小。可在存储目标中设置 **备份配额**,显示备份配额使用率。这是 BackupX 执行的软限额,不是桶配额,也不是 MinIO 集群磁盘总容量;统计不包含其他应用写入的对象或未被记录的对象版本。集群物理容量与桶容量请使用 MinIO 控制台或监控接口查询。 + ## Rclone 后端 每一种 [rclone 后端](https://rclone.org/overview/) 都作为一等公民暴露 — SFTP、Azure Blob、Dropbox、OneDrive、Backblaze B2、Wasabi、pCloud、HDFS 等。 diff --git a/server/internal/backup/helpers.go b/server/internal/backup/helpers.go index c8d7a93..b088c02 100644 --- a/server/internal/backup/helpers.go +++ b/server/internal/backup/helpers.go @@ -6,6 +6,7 @@ import ( "path/filepath" "strings" "time" + "unicode" ) func createTempArtifact(baseDir, taskName string, extension string) (string, string, error) { @@ -29,7 +30,7 @@ func sanitizeFileName(value string) string { builder.WriteRune(char) case char >= 'A' && char <= 'Z': builder.WriteRune(char + ('a' - 'A')) - case char >= '0' && char <= '9': + case unicode.IsLetter(char) || unicode.IsNumber(char): builder.WriteRune(char) case char == '-' || char == '_': builder.WriteRune(char) diff --git a/server/internal/backup/temp_files.go b/server/internal/backup/temp_files.go index cce68c5..0815709 100644 --- a/server/internal/backup/temp_files.go +++ b/server/internal/backup/temp_files.go @@ -9,7 +9,7 @@ import ( "time" ) -var fileNameCleaner = regexp.MustCompile(`[^a-zA-Z0-9._-]+`) +var fileNameCleaner = regexp.MustCompile(`[^\p{L}\p{N}._-]+`) func EnsureTempRoot() (string, error) { root := filepath.Join(os.TempDir(), "backupx") diff --git a/server/internal/backup/temp_files_test.go b/server/internal/backup/temp_files_test.go new file mode 100644 index 0000000..ddeea9f --- /dev/null +++ b/server/internal/backup/temp_files_test.go @@ -0,0 +1,62 @@ +package backup + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestArtifactNamesPreserveUnicodeAndRejectUnsafeCharacters(t *testing.T) { + startedAt := time.Date(2026, 10, 6, 1, 2, 3, 0, time.UTC) + cases := []struct { + name string + legacy string + shared string + }{ + {"中文备份", "中文备份", "中文备份"}, + {" MySQL 数据库 ", "mysql_数据库", "mysql-数据库"}, + {"../目录\\备份:\x00?*", "目录备份", "目录-备份"}, + {"... /\\:*?", "", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := sanitizeFileName(tc.name); got != tc.legacy { + t.Fatalf("sanitizeFileName(%q) = %q, want %q", tc.name, got, tc.legacy) + } + if got := sanitizeTaskName(tc.name); got != tc.shared { + t.Fatalf("sanitizeTaskName(%q) = %q, want %q", tc.name, got, tc.shared) + } + prefix := tc.shared + if prefix == "" { + prefix = "backup" + } + if got := BuildArtifactName(tc.name, startedAt, "sql"); got != prefix+"_20261006_010203.sql" { + t.Fatalf("unexpected artifact name: %q", got) + } + tempDir, artifactPath, err := createTempArtifact(t.TempDir(), tc.name, "tar") + if err != nil { + t.Fatal(err) + } + prefix = tc.legacy + if prefix == "" { + prefix = "backup" + } + if filepath.Dir(artifactPath) != tempDir || !strings.HasPrefix(filepath.Base(artifactPath), prefix+"_") { + t.Fatalf("unsafe or incorrect artifact path: %q", artifactPath) + } + }) + } + t.Setenv("TMPDIR", t.TempDir()) + taskDir, err := CreateTaskTempDir("中文备份", startedAt) + if err != nil { + t.Fatal(err) + } + if filepath.Base(taskDir) != "中文备份_20261006_010203" { + t.Fatalf("unexpected task directory: %q", taskDir) + } + if _, err := os.Stat(taskDir); err != nil { + t.Fatal(err) + } +} diff --git a/server/internal/installscript/renderer.go b/server/internal/installscript/renderer.go index d61ee9b..3ca855a 100644 --- a/server/internal/installscript/renderer.go +++ b/server/internal/installscript/renderer.go @@ -39,7 +39,7 @@ type Context struct { func DownloadBaseFor(src string) string { switch src { case model.InstallSourceGhproxy: - return "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download" + return "https://ghfast.top/https://github.com/Awuqing/BackupX/releases/download" default: return "https://github.com/Awuqing/BackupX/releases/download" } diff --git a/server/internal/installscript/renderer_test.go b/server/internal/installscript/renderer_test.go index 209448f..aaab4fb 100644 --- a/server/internal/installscript/renderer_test.go +++ b/server/internal/installscript/renderer_test.go @@ -332,7 +332,7 @@ func TestRenderScriptRejectsBadVersion(t *testing.T) { func TestDownloadBaseMapping(t *testing.T) { cases := map[string]string{ model.InstallSourceGitHub: "https://github.com/Awuqing/BackupX/releases/download", - model.InstallSourceGhproxy: "https://ghproxy.com/https://github.com/Awuqing/BackupX/releases/download", + model.InstallSourceGhproxy: "https://ghfast.top/https://github.com/Awuqing/BackupX/releases/download", } for src, want := range cases { got := DownloadBaseFor(src) diff --git a/web/package-lock.json b/web/package-lock.json index c6e15cb..5156e00 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0", "dependencies": { "@arco-design/web-react": "^2.66.16", - "axios": "^1.19.0", + "axios": "^1.20.0", "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", "i18next": "^25.8.14", @@ -2261,9 +2261,9 @@ "license": "MIT" }, "node_modules/axios": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.19.0.tgz", - "integrity": "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw==", + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { "follow-redirects": "^1.16.0", diff --git a/web/package.json b/web/package.json index 0f4f983..625baec 100644 --- a/web/package.json +++ b/web/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@arco-design/web-react": "^2.66.16", - "axios": "^1.19.0", + "axios": "^1.20.0", "echarts": "^6.1.0", "echarts-for-react": "^3.0.6", "i18next": "^25.8.14", diff --git a/web/src/pages/nodes/wizard/Step2DeployOptions.tsx b/web/src/pages/nodes/wizard/Step2DeployOptions.tsx index 5e0fdbf..3b74b70 100644 --- a/web/src/pages/nodes/wizard/Step2DeployOptions.tsx +++ b/web/src/pages/nodes/wizard/Step2DeployOptions.tsx @@ -107,7 +107,7 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) { 国内服务器选 ghproxy 镜像加速} + extra={国内服务器可选 GitHub 镜像加速} > update({ downloadSrc: v as InstallSource })} options={[ { label: 'GitHub 直连', value: 'github' }, - { label: 'ghproxy 镜像', value: 'ghproxy' }, + { label: 'GitHub 镜像(ghfast.top)', value: 'ghproxy' }, ]} /> diff --git a/web/src/pages/storage-targets/StorageTargetsPage.test.tsx b/web/src/pages/storage-targets/StorageTargetsPage.test.tsx new file mode 100644 index 0000000..83a9551 --- /dev/null +++ b/web/src/pages/storage-targets/StorageTargetsPage.test.tsx @@ -0,0 +1,82 @@ +import { cleanup, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { StorageTargetsPage } from './StorageTargetsPage' +import { getStorageTargetUsage, listStorageTargets } from '../../services/storage-targets' + +vi.mock('../../services/storage-targets', () => ({ + listStorageTargets: vi.fn(), + getStorageTargetUsage: vi.fn(), + createStorageTarget: vi.fn(), + deleteStorageTarget: vi.fn(), + getStorageTarget: vi.fn(), + startGoogleDriveAuth: vi.fn(), + testSavedStorageTarget: vi.fn(), + testStorageTarget: vi.fn(), + toggleStorageTargetStar: vi.fn(), + updateStorageTarget: vi.fn(), +})) +vi.mock('../../components/storage-targets/StorageTargetFormDrawer', () => ({ + StorageTargetFormDrawer: () => null, +})) + +afterEach(cleanup) + +function mockTarget(quotaBytes = 0) { + vi.mocked(listStorageTargets).mockResolvedValue([ + { + id: 1, + name: 'MinIO', + type: 's3', + description: '', + enabled: true, + starred: false, + updatedAt: '2026-10-06', + lastTestStatus: 'success', + quotaBytes, + }, + ]) +} + +describe('storage usage', () => { + it('shows zero tracked bytes and explains missing physical capacity', async () => { + mockTarget() + vi.mocked(getStorageTargetUsage).mockResolvedValue({ + targetId: 1, + targetName: 'MinIO', + recordCount: 0, + totalSize: 0, + }) + render() + expect(await screen.findByText('备份记录大小:0 B')).toBeInTheDocument() + expect(screen.getByText(/未获取到存储总容量/)).toBeInTheDocument() + expect(screen.queryByText(/使用率.*%/)).not.toBeInTheDocument() + }) + + it('labels the configured backup quota without pretending it is disk capacity', async () => { + mockTarget(1024) + vi.mocked(getStorageTargetUsage).mockResolvedValue({ + targetId: 1, + targetName: 'MinIO', + recordCount: 0, + totalSize: 512, + }) + render() + expect(await screen.findByText('备份配额使用率 50%')).toBeInTheDocument() + expect(screen.getByText(/不代表存储物理容量/)).toBeInTheDocument() + expect(screen.queryByText(/0 个记录/)).not.toBeInTheDocument() + }) + + it('uses backend capacity in preference to the backup quota', async () => { + mockTarget(1024) + vi.mocked(getStorageTargetUsage).mockResolvedValue({ + targetId: 1, + targetName: 'MinIO', + recordCount: 0, + totalSize: 512, + diskUsage: { total: 4096, used: 1024 }, + }) + render() + expect(await screen.findByText('使用率 25%')).toBeInTheDocument() + expect(screen.queryByText(/备份配额使用率/)).not.toBeInTheDocument() + }) +}) diff --git a/web/src/pages/storage-targets/StorageTargetsPage.tsx b/web/src/pages/storage-targets/StorageTargetsPage.tsx index 513e862..bc65423 100644 --- a/web/src/pages/storage-targets/StorageTargetsPage.tsx +++ b/web/src/pages/storage-targets/StorageTargetsPage.tsx @@ -298,38 +298,53 @@ export function StorageTargetsPage() { const usage = usageMap[target.id] if (!usage) return null const disk = usage.diskUsage - // 优先后端 About(远端真实容量),否则展示"已用量"(累计备份大小) - if (disk && disk.total && disk.used !== undefined) { - const rate = disk.total > 0 ? disk.used / disk.total : 0 + // 真实容量优先;S3 等未提供容量的后端可按备份软配额展示。 + const hasDiskCapacity = disk && disk.total && disk.used !== undefined + const total = hasDiskCapacity ? disk.total : target.quotaBytes + const used = hasDiskCapacity ? disk.used : usage.totalSize + if (total && total > 0 && used !== undefined) { + const rate = used / total const percent = Math.round(rate * 100) const color = rate >= 0.85 ? '#F53F3F' : rate >= 0.7 ? '#FF7D00' : '#00B42A' return (
- 使用率 {percent}% + {hasDiskCapacity ? '使用率' : '备份配额使用率'} {percent}% - {formatBytes(disk.used)} / {formatBytes(disk.total)} + {formatBytes(used)} / {formatBytes(total)} {rate >= 0.85 && ( - 容量预警 + {hasDiskCapacity ? '容量预警' : '配额预警'} )} - + + {!hasDiskCapacity && ( + + 按备份记录大小与设置配额计算,不代表存储物理容量。 + + )}
) } - if (usage.totalSize > 0) { - return ( + return ( + - 已用备份:{formatBytes(usage.totalSize)}({usage.recordCount} 个记录) + 备份记录大小:{formatBytes(usage.totalSize)} - ) - } - return null + + 未获取到存储总容量,无法计算物理空间使用率;可设置备份配额。 + + + ) })()} 更新时间:{target.updatedAt}