Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs-site/docs/features/storage-backends.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 等。
Expand Down
3 changes: 2 additions & 1 deletion server/internal/backup/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"time"
"unicode"
)

func createTempArtifact(baseDir, taskName string, extension string) (string, string, error) {
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion server/internal/backup/temp_files.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
62 changes: 62 additions & 0 deletions server/internal/backup/temp_files_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 1 addition & 1 deletion server/internal/installscript/renderer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion server/internal/installscript/renderer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
8 changes: 4 additions & 4 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 2 additions & 2 deletions web/src/pages/nodes/wizard/Step2DeployOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,15 +107,15 @@ export function Step2DeployOptions({ masterVersion, value, onChange }: Props) {

<Form.Item
label="二进制下载源"
extra={<Text type="secondary">国内服务器选 ghproxy 镜像加速</Text>}
extra={<Text type="secondary">国内服务器可选 GitHub 镜像加速</Text>}
>
<Radio.Group
type="button"
value={value.downloadSrc}
onChange={(v) => update({ downloadSrc: v as InstallSource })}
options={[
{ label: 'GitHub 直连', value: 'github' },
{ label: 'ghproxy 镜像', value: 'ghproxy' },
{ label: 'GitHub 镜像(ghfast.top)', value: 'ghproxy' },
]}
/>
</Form.Item>
Expand Down
82 changes: 82 additions & 0 deletions web/src/pages/storage-targets/StorageTargetsPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<StorageTargetsPage />)
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(<StorageTargetsPage />)
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(<StorageTargetsPage />)
expect(await screen.findByText('使用率 25%')).toBeInTheDocument()
expect(screen.queryByText(/备份配额使用率/)).not.toBeInTheDocument()
})
})
41 changes: 28 additions & 13 deletions web/src/pages/storage-targets/StorageTargetsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<Space size="mini" style={{ marginBottom: 4 }}>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
使用率 {percent}%
{hasDiskCapacity ? '使用率' : '备份配额使用率'} {percent}%
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatBytes(disk.used)} / {formatBytes(disk.total)}
{formatBytes(used)} / {formatBytes(total)}
</Typography.Text>
{rate >= 0.85 && (
<Tag color="red" bordered size="small">
容量预警
{hasDiskCapacity ? '容量预警' : '配额预警'}
</Tag>
)}
</Space>
<Progress percent={percent} color={color} size="small" showText={false} />
<Progress
percent={Math.min(100, percent)}
color={color}
size="small"
showText={false}
/>
{!hasDiskCapacity && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
按备份记录大小与设置配额计算,不代表存储物理容量。
</Typography.Text>
)}
</div>
)
}
if (usage.totalSize > 0) {
return (
return (
<Space direction="vertical" size="mini">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
已用备份:{formatBytes(usage.totalSize)}({usage.recordCount} 个记录)
备份记录大小:{formatBytes(usage.totalSize)}
</Typography.Text>
)
}
return null
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
未获取到存储总容量,无法计算物理空间使用率;可设置备份配额。
</Typography.Text>
</Space>
)
})()}
<Typography.Text type="secondary">更新时间:{target.updatedAt}</Typography.Text>

Expand Down
Loading